<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>gitit-sehyeon.log</title>
        <link>https://velog.io/</link>
        <description>I'm the best</description>
        <lastBuildDate>Mon, 24 Aug 2026 01:27:52 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>gitit-sehyeon.log</title>
            <url>https://velog.velcdn.com/images/gitit-sehyeon/profile/b23f5018-2d8c-4c45-9495-7f151a242d8d/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. gitit-sehyeon.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/gitit-sehyeon" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[남이 만든 HTML을 우리 화면에 그대로 띄우기 — CSP sandbox와 X-Frame-Options가 서로를 죽인다]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EB%82%A8%EC%9D%B4-%EB%A7%8C%EB%93%A0-HTML%EC%9D%84-%EC%9A%B0%EB%A6%AC-%ED%99%94%EB%A9%B4%EC%97%90-%EA%B7%B8%EB%8C%80%EB%A1%9C-%EB%9D%84%EC%9A%B0%EA%B8%B0-CSP-sandbox%EC%99%80-X-Frame-Options%EA%B0%80-%EC%84%9C%EB%A1%9C%EB%A5%BC-%EC%A3%BD%EC%9D%B8%EB%8B%A4</link>
            <guid>https://velog.io/@gitit-sehyeon/%EB%82%A8%EC%9D%B4-%EB%A7%8C%EB%93%A0-HTML%EC%9D%84-%EC%9A%B0%EB%A6%AC-%ED%99%94%EB%A9%B4%EC%97%90-%EA%B7%B8%EB%8C%80%EB%A1%9C-%EB%9D%84%EC%9A%B0%EA%B8%B0-CSP-sandbox%EC%99%80-X-Frame-Options%EA%B0%80-%EC%84%9C%EB%A1%9C%EB%A5%BC-%EC%A3%BD%EC%9D%B8%EB%8B%A4</guid>
            <pubDate>Mon, 24 Aug 2026 01:27:52 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/14334025-d6ef-4862-b85d-bfe84bb9a9f0/image.png" alt=""></p>
<blockquote>
<p>사내 코드라 호스트·조직명은 바꿨다. 구조와 실측값은 그대로다.</p>
</blockquote>
<hr>
<h2 id="0-요구사항이-뒤집혔다">0. 요구사항이 뒤집혔다</h2>
<p>채용 현황 탭이 있었다. 우리가 데이터를 모아 퍼널·공고 표·입사 예정자 목록을 그리는 화면이었다. 목업 상태로 만들어 뒀고 실제 데이터 연결을 기다리고 있었다.</p>
<p>그런데 확인해 보니 인사팀은 이미 <strong>영업일마다 완성된 대시보드 HTML을 두레이 업무에 올리고 있었다.</strong> 첨부 60개, 파일당 1.4~2.8MB, 통합/그룹별/내부 3종.</p>
<p>그래서 방향을 바꿨다. 우리가 그리는 걸 걷어내고 <strong>그 원본을 왼쪽 목록에서 골라 오른쪽에 그대로 띄운다.</strong></p>
<p>간단해 보이는 일이었다. 남이 만든 2.8MB HTML을 iframe에 넣으면 끝. 실제로는 브라우저 보안 헤더, 개인정보, 리다이렉트, 메타데이터 결핍까지 다 걸렸다. 그 기록이다.</p>
<hr>
<h2 id="1-첫-판단--파싱하지-않는다">1. 첫 판단 — 파싱하지 않는다</h2>
<p>파일을 열어 보니 안에 이런 게 있었다.</p>
<pre><code class="language-html">&lt;script&gt;
  window.__DATA__ = { applicants: [ /* 5,724행 */ ] , … };
&lt;/script&gt;</code></pre>
<p><strong>지원자 원본 데이터가 통째로 들어 있다.</strong> 그러니까 우리가 이걸 파싱해서 원래 만들려던 퍼널·공고 표를 진짜 데이터로 그릴 수도 있다. 유혹적이다.</p>
<p>안 했다.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;왜 iframe 인가.&lt;/b&gt; 이 파일은 2.8MB 짜리 완성된 페이지다(자체 스타일 + Chart.js +
 * 지원자 원본 데이터). 우리가 뜯어 다시 그리면 인사팀 원본과 어긋난 두 번째 화면이 생긴다 —
 * 그대로 보여 주는 것이 이 기능의 목적이다.
 */</code></pre>
<p>이유는 기술이 아니다. <strong>두 번째 진실이 생긴다.</strong></p>
<p>인사팀이 만든 대시보드가 &quot;채용 현황&quot;의 원본이다. 우리가 같은 데이터로 다시 집계하면 숫자가 어긋나는 날이 온다 — 필터 조건 하나, 상태 매핑 하나, 집계 시점 하나 차이로. 그날 조직장은 두 숫자를 보고 어느 쪽을 믿어야 할지 물어야 한다. 그리고 그 질문에 답할 사람은 우리다.</p>
<p><strong>남의 데이터를 재집계하는 순간 그 도메인의 정확성을 우리가 떠안는다.</strong> 우리 팀에 채용 데이터의 정의를 아는 사람이 없다면, 재집계는 기술 부채가 아니라 도메인 부채다.</p>
<p>부수적으로 파싱을 안 하니 <code>window.__DATA__</code>의 스키마 변화에 우리가 깨지지 않는다. 인사팀이 대시보드를 개편해도 우리 코드는 그대로다. 결합을 안 만드는 게 이득인 경우다.</p>
<hr>
<h2 id="2-두-번째-판단--내용을-저장하지도-캐시하지도-않는다">2. 두 번째 판단 — 내용을 저장하지도 캐시하지도 않는다</h2>
<p>그 5,724행에 <strong>전 법인 지원자의 이름과 생년월일</strong>이 들어 있다.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;내용은 저장하지도 캐시하지도 않는다.&lt;/b&gt; 파일 안에 전 법인 지원자 5천여 명의
 * 이름·생년월일이 들어 있어 어딘가에 남기는 순간 그 개인정보를 보관하게 된다. 응답에
 * {@code no-store} 를 붙이는 이유도 같다 — 브라우저 디스크 캐시에도 남기지 않는다.
 */</code></pre>
<p>그래서 요청마다 두레이에서 받아 그대로 흘려보낸다. 캐시하는 건 <strong>목록만</strong>이다.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;목록만 캐시한다&lt;/b&gt;(파일명·크기뿐이라 개인정보가 아니다). 내용은 캐시하지 않는다 —
 * 안에 전 법인 지원자 이름·생년월일이 들어 있다.
 */</code></pre>
<p>여기서 하나 배웠다. <strong>캐시 경계를 성능이 아니라 데이터 등급으로 그었다.</strong> 목록(파일명·크기)과 내용(개인정보)은 같은 API에서 나오지만 캐시 가능성이 완전히 다르다. 대개 캐시 설계를 &quot;얼마나 자주 바뀌나&quot;로 하는데, 개인정보가 섞이면 그 질문보다 &quot;어디에 남아도 되나&quot;가 먼저다.</p>
<p>그리고 권한 판정을 따로 안 만들었다.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;토큰은 화면을 보는 사람 것이다.&lt;/b&gt; 서비스 계정이 아니라 각자 등록한 개인 토큰이라,
 * 그 업무를 볼 수 있는 사람에게만 목록이 보인다 — 권한 판정을 우리가 따로 만들지 않아도 된다.
 */</code></pre>
<p>원본 시스템의 권한을 그대로 상속받는다. <strong>자격을 위임받아 부르면 인가가 공짜다.</strong> 서비스 계정으로 부르면 그 순간 &quot;누가 이걸 볼 수 있나&quot;를 우리가 정의해야 하고, 그 정의는 원본 시스템의 정의와 갈린다.</p>
<hr>
<h2 id="3-격리--sandbox를-이중으로-건다">3. 격리 — sandbox를 이중으로 건다</h2>
<p>우리가 만들지 않은 스크립트가 우리 화면 안에서 돈다. Chart.js가 들어 있고 필터·정렬이 다 자체 스크립트다. 스크립트를 죽이면 대시보드가 아니라 빈 페이지가 된다.</p>
<p>서버 응답 헤더:</p>
<pre><code class="language-java">private static final String SANDBOX =
    &quot;sandbox allow-scripts allow-downloads allow-popups; frame-ancestors &#39;self&#39;&quot;;</code></pre>
<p>화면의 iframe:</p>
<pre><code class="language-tsx">&lt;iframe
  // key 가 파일 id 라 프레임이 파일마다 하나씩 유지된다 — 같은 요소를 재사용하면 돌아올 때
  // 다시 그려야 한다.
  key={mounted.fileId}
  src={src}
  title={mounted.fileName}
  className={cn(
    &#39;absolute inset-0 h-full w-full&#39;,
    // 안 보이는 프레임도 크기는 유지한다(`invisible`) — `hidden` 이면 안에서 차트가 0폭으로
    // 다시 레이아웃됐다가 돌아올 때 또 그린다.
    mounted.fileId !== file.fileId &amp;&amp; &#39;invisible&#39;,
  )}
  sandbox=&quot;allow-scripts allow-downloads allow-popups&quot;
/&gt;</code></pre>
<p><code>invisible</code>과 <code>hidden</code>의 차이가 실제로 성능을 갈랐다. <code>display: none</code>이면 프레임 안에서 차트가 폭 0으로 다시 레이아웃되고, 돌아올 때 또 그린다 — 미리 그려 둔 의미가 사라진다. 미리 렌더링을 숨기는 방법은 <strong>레이아웃을 유지하는 쪽</strong>이어야 한다.</p>
<p>핵심은 <strong><code>allow-same-origin</code>을 주지 않는 것</strong>이다.</p>
<pre><code>/**
 * &lt;p&gt;&lt;b&gt;sandbox 에서 `allow-same-origin` 을 주지 않는다.&lt;/b&gt; 스크립트는 돌아야 하므로
 * `allow-scripts` 는 주되, 둘을 함께 주면 sandbox 가 사실상 해제돼(문서가 우리 origin 을
 * 그대로 갖는다) 이 페이지가 우리 쿠키·스토리지에 닿을 수 있다.
 */</code></pre><p><code>allow-scripts</code> + <code>allow-same-origin</code>을 함께 주는 것은 sandbox를 해제하는 것과 거의 같다. 문서가 우리 origin을 그대로 갖고 스크립트가 돌면, 그 스크립트가 자기를 담은 프레임의 sandbox 속성을 지우고 다시 로드할 수 있다. MDN도 이 조합을 명시적으로 경고한다.</p>
<p>우리가 준 것만 보면 이렇다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>왜</th>
</tr>
</thead>
<tbody><tr>
<td><code>allow-scripts</code></td>
<td>차트·필터가 자체 스크립트다. 없으면 빈 페이지</td>
</tr>
<tr>
<td><code>allow-downloads</code></td>
<td>대시보드에 엑셀 내려받기 버튼이 있다</td>
</tr>
<tr>
<td><code>allow-popups</code></td>
<td>원본이 상세를 새 창으로 연다</td>
</tr>
<tr>
<td><del><code>allow-same-origin</code></del></td>
<td><strong>주지 않는다.</strong> origin이 opaque로 남아 우리 쿠키·스토리지에 못 닿는다</td>
</tr>
</tbody></table>
<p>이중으로 거는 이유는 단순하다. 화면 코드는 우리가 언제든 바꾼다. 서버 헤더는 <strong>그 문서 자체에 붙어 있어</strong> 새 탭으로 직접 열어도 유효하다.</p>
<p>그리고 바로 여기서 함정을 밟았다.</p>
<hr>
<h2 id="4-핵심-함정--opaque-origin에-x-frame-options-sameorigin을-붙이면-자기-화면에서도-빈-프레임이-된다">4. 핵심 함정 — opaque origin에 <code>X-Frame-Options: SAMEORIGIN</code>을 붙이면 자기 화면에서도 빈 프레임이 된다</h2>
<p>삽입 제한을 걸어야 한다. 이 응답이 아무 사이트에나 담기면 안 된다. 반사적으로 <code>X-Frame-Options: SAMEORIGIN</code>을 썼다.</p>
<p><strong>우리 화면 안에서 빈 프레임이 됐다.</strong> 주소창으로 직접 열면 멀쩡히 뜬다. iframe에 넣으면 빈 화면이다.</p>
<p>원인은 두 헤더의 상호작용이다.</p>
<ul>
<li><code>Content-Security-Policy: sandbox</code>에 <code>allow-same-origin</code>이 없으면 그 문서의 origin은 <strong>opaque</strong>가 된다.</li>
<li><code>X-Frame-Options: SAMEORIGIN</code>은 <strong>문서 자신의 origin</strong>과 담는 쪽 origin이 같은지 본다.</li>
<li>opaque origin은 <strong>자기 자신을 포함해 어떤 origin과도 같지 않다.</strong></li>
</ul>
<p>그래서 우리 화면(같은 서버에서 온 페이지)에 담아도 SAMEORIGIN 검사에 실패한다. <code>X-Frame-Options</code>는 sandbox를 전제하지 않고 만들어진 헤더라 이 조합에서 의미가 무너진다.</p>
<p>해법은 <code>frame-ancestors</code>다.</p>
<pre><code class="language-java">/**
 * 격리 수준. {@code allow-same-origin} 이 없어 문서는 opaque origin 이 되고, 우리 쿠키·
 * 스토리지에 닿지 못한다. {@code allow-downloads} 는 대시보드의 엑셀 내려받기 버튼 때문에 준다.
 *
 * &lt;p&gt;&lt;b&gt;삽입 제한은 {@code frame-ancestors} 로 한다 — {@code X-Frame-Options} 로는 안 된다.&lt;/b&gt;
 * 실측: sandbox 로 origin 이 opaque 가 된 문서에 {@code X-Frame-Options: SAMEORIGIN} 을 붙이면
 * 브라우저가 우리 화면 안에서도 &lt;b&gt;빈 프레임&lt;/b&gt;으로 막는다(주소창으로 직접 열면 멀쩡히 뜬다).
 * {@code frame-ancestors &#39;self&#39;} 는 문서 자신이 아니라 &lt;b&gt;담는 쪽&lt;/b&gt;의 origin 을 보므로 그 함정이
 * 없고, 다른 사이트가 이 응답을 담는 것은 그대로 막힌다.
 */
private static final String SANDBOX =
    &quot;sandbox allow-scripts allow-downloads allow-popups; frame-ancestors &#39;self&#39;&quot;;</code></pre>
<p><strong>차이가 한 줄로 정리된다.</strong></p>
<table>
<thead>
<tr>
<th>헤더</th>
<th>무엇의 origin을 보나</th>
<th>opaque 문서에서</th>
</tr>
</thead>
<tbody><tr>
<td><code>X-Frame-Options: SAMEORIGIN</code></td>
<td><strong>담기는 문서 자신</strong>의 origin</td>
<td>자기 자신과도 안 맞아 전부 차단</td>
</tr>
<tr>
<td><code>CSP: frame-ancestors &#39;self&#39;</code></td>
<td><strong>담는 쪽(부모)</strong> 의 origin</td>
<td>문서의 origin과 무관하게 정상 동작</td>
</tr>
</tbody></table>
<p><code>frame-ancestors</code>가 <code>X-Frame-Options</code>를 대체하는 신규 표준이라는 건 알려진 사실이다. 그런데 대체 이유로 보통 &quot;여러 origin 허용&quot;, &quot;리다이렉트 처리&quot;가 언급되고, <strong>sandbox와의 상호작용</strong>은 잘 안 나온다. 이 조합(<code>CSP: sandbox</code> + <code>XFO: SAMEORIGIN</code>)을 쓸 이유가 흔치 않기 때문이다. 남의 HTML을 격리해서 자기 화면에 띄우려는 순간 정확히 그 조합이 필요해진다.</p>
<p>응답 헤더 전체는 이렇다.</p>
<pre><code class="language-java">return ResponseEntity.ok()
    .contentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8))
    .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
        .filename(content.fileName(), StandardCharsets.UTF_8).build().toString())
    .header(&quot;Content-Security-Policy&quot;, SANDBOX)
    .header(HttpHeaders.CACHE_CONTROL, &quot;no-store&quot;)
    .header(&quot;X-Content-Type-Options&quot;, &quot;nosniff&quot;)
    .body(content.html());</code></pre>
<hr>
<h2 id="5-함정-2--spring-security가-컨트롤러-헤더를-덮는다">5. 함정 2 — Spring Security가 컨트롤러 헤더를 덮는다</h2>
<p>헤더를 고쳐도 안 됐다. Spring Security 기본값이 <strong>모든 응답에 <code>X-Frame-Options: DENY</code></strong> 를 걸고, <strong>컨트롤러가 넣어 둔 값도 덮어쓴다.</strong></p>
<p>그래서 그 경로 하나만 기본 writer에서 뺐다.</p>
<pre><code class="language-java">/**
 * 채용 대시보드 원본 응답 &lt;b&gt;하나만&lt;/b&gt; 우리 화면 안 iframe 에 담기게 한다.
 *
 * &lt;p&gt;Spring Security 기본값은 모든 응답에 {@code X-Frame-Options: DENY} 를 건다(컨트롤러가
 * 넣어 둔 값도 덮어쓴다 — 실측). 그래서 &lt;b&gt;그 경로에만 기본 writer 를 적용하지 않는다&lt;/b&gt;.
 * 나머지 경로는 그대로 DENY 다 — 이 앱 화면이 남의 사이트에 담기는 것은 여전히 막는다.
 */
private void allowFramingRecruitDashboard(HttpSecurity http) throws Exception {
    RequestMatcher dashboardContent = PathPatternRequestMatcher.withDefaults()
        .matcher(HttpMethod.GET, &quot;/console/v1/recruit/dashboards/*&quot;);

    http.headers(headers -&gt; headers
        .frameOptions(frame -&gt; frame.disable())
        .addHeaderWriter(new DelegatingRequestMatcherHeaderWriter(
            new NegatedRequestMatcher(dashboardContent),
            new XFrameOptionsHeaderWriter(XFrameOptionsMode.DENY))));
}</code></pre>
<p><code>frameOptions().disable()</code>만 부르면 <strong>앱 전체</strong>가 프레이밍 가능해진다. 그건 클릭재킹 문을 여는 것이다. 그래서 끄고 나서 <code>NegatedRequestMatcher</code>로 <strong>그 경로만 뺀 나머지 전부</strong>에 DENY를 다시 건다.</p>
<p>패턴으로 정리하면 이렇다. <strong>전역 보안 기본값에서 한 경로만 예외로 빼야 할 때, 기본값을 끄고 예외를 만들지 말고 — 기본값을 끄고 &quot;예외를 제외한 전부&quot;에 다시 걸어라.</strong> 새 경로가 추가되면 자동으로 보호받는다. 화이트리스트가 아니라 명시적 블랙리스트의 여집합이다.</p>
<p>이 메서드는 alpha 체인과 local 체인 양쪽에서 부른다. 로컬에서 안 되고 알파에서만 되는(또는 반대인) 상태를 만들면 검증이 안 된다.</p>
<hr>
<h2 id="6-함정-3--307-리다이렉트가-authorization을-떨어뜨린다">6. 함정 3 — 307 리다이렉트가 <code>Authorization</code>을 떨어뜨린다</h2>
<p>첨부 내려받기 실측 결과.</p>
<table>
<thead>
<tr>
<th>요청</th>
<th>응답</th>
</tr>
</thead>
<tbody><tr>
<td><code>GET /project/v1/projects/{p}/posts/{q}/files/{f}?media=raw</code> (인증 헤더 있음)</td>
<td><strong>307</strong> + <code>Location: https://file-api.…/downloads/…</code></td>
</tr>
<tr>
<td>그 <code>Location</code> 을 인증 헤더 <strong>없이</strong> 요청</td>
<td><strong>401</strong></td>
</tr>
<tr>
<td>그 <code>Location</code> 을 인증 헤더 <strong>실어서</strong> 요청</td>
<td><strong>200</strong></td>
</tr>
</tbody></table>
<p>문제는 HTTP 클라이언트가 리다이렉트를 자동으로 따라갈 때 <strong>보통 <code>Authorization</code>을 떼고 간다</strong>는 것이다(다른 호스트로 자격이 새는 것을 막는 안전장치다. curl도 <code>--location-trusted</code> 없이는 그렇다). 그래서 그냥 부르면 401이 난다.</p>
<p>그래서 따라가지 않게 해 두고 직접 다시 부른다.</p>
<pre><code class="language-java">/**
 * 첨부 원본 바이트.
 *
 * &lt;p&gt;&lt;b&gt;두 번 부른다.&lt;/b&gt; 이 경로는 {@code 307} 로 파일 서버를 가리키고, 리다이렉트를 자동으로
 * 따라가는 클라이언트는 보통 {@code Authorization} 을 떼기 때문에 거기서 {@code 401} 이 난다.
 * 그래서 리다이렉트를 &lt;b&gt;따라가지 않게&lt;/b&gt; 해 두고 {@code Location} 을 받아 인증 헤더를 다시
 * 실어 보낸다.
 *
 * &lt;p&gt;따라갈 곳은 &lt;b&gt;두레이 도메인 안&lt;/b&gt;으로 제한한다 — 응답이 가리키는 곳이면 어디든 우리
 * 토큰을 실어 보내는 클라이언트가 되면 안 된다.
 */
public byte[] downloadPostFile(String apiKey, String projectId, String postId,
                               String fileId, long maxBytes) {
    ResponseEntity&lt;byte[]&gt; response = getBytes(apiKey, URI.create(baseUrl + path), path);

    if (response.getStatusCode().is3xxRedirection()) {
        URI location = response.getHeaders().getLocation();
        if (location == null || !isDoorayUri(location)) {
            throw new DoorayApiException(&quot;첨부 내려받기가 두레이 밖(&quot; + hostOf(location)
                + &quot;)을 가리켜 따라가지 않았습니다. path=&quot; + pathOf(path));
        }
        response = getBytes(apiKey, location, path);
    }
    …
}</code></pre>
<p><strong>두 번째 문단이 중요하다.</strong> 자동 리다이렉트를 끄고 손으로 따라가는 순간, 브라우저·라이브러리가 대신 해 주던 안전장치가 사라진다. 그 순간 우리는 <strong>응답이 가리키는 아무 호스트에나 API 토큰을 실어 보내는 클라이언트</strong>가 될 수 있다. 원격 응답이 우리 자격의 목적지를 정하는 것 — SSRF의 사촌이다.</p>
<p>그래서 <code>Location</code>의 호스트를 화이트리스트로 검사한다. 자동 리다이렉트를 끄는 코드를 쓸 때마다 붙여야 하는 세 줄이다.</p>
<p>부가로 챙긴 것 두 개.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;여기서도 레이트리미터를 지난다. 리다이렉트를 따라가면 요청이 2회라, 세지 않으면
 * 예산이 어긋난다.
 */</code></pre>
<pre><code class="language-java">if (body.length &gt; maxBytes) {
    throw new DoorayApiException(&quot;첨부가 허용 크기를 넘습니다(&quot; + body.length + &quot; &gt; &quot; + maxBytes
        + &quot;바이트). app.recruit.dashboard.max-file-bytes 를 확인해야 합니다. …&quot;);
}</code></pre>
<p>크기 상한 에러 메시지에 <strong>바꿀 설정 키 이름을 적어 뒀다.</strong> 1.4~2.8MB짜리를 다루는 코드라 언젠가 이 한계에 부딪힌다. 그때 &quot;허용 크기를 넘습니다&quot;만 보이면 어디를 고쳐야 할지 코드를 뒤져야 한다.</p>
<hr>
<h2 id="7-함정-4--메타데이터가-없다-파일명이-유일한-진실">7. 함정 4 — 메타데이터가 없다. 파일명이 유일한 진실</h2>
<p>두레이 업무 첨부 목록은 업무 상세 응답 안에 통째로 온다. 그런데 키가 세 개뿐이다.</p>
<pre><code class="language-json">{ &quot;id&quot;: &quot;…&quot;, &quot;name&quot;: &quot;26.08.18_통합채용대시보드.html&quot;, &quot;size&quot;: 1843200 }</code></pre>
<p><strong>올린 시각이 없다.</strong> 기준일도 정렬 근거도 없다. 그래서 파일명에서 읽어야 한다.</p>
<pre><code class="language-java">/**
 * {@code 26.08.18_통합채용대시보드.html} 을 기준일 + 종류로 가른다.
 *
 * &lt;p&gt;종류 자리는 비어 있을 수 있다 — 7월 초에는 {@code 26.07.03_채용대시보드.html} 처럼 종류
 * 없이 올라왔다. 규칙이 안 맞는 이름도 &lt;b&gt;목록에서 빼지 않는다&lt;/b&gt;(기준일 없음 + 기타로 남긴다).
 */
private static final Pattern FILE_NAME = Pattern.compile(
    &quot;^(\\d{2})\\.(\\d{2})\\.(\\d{2})_(.*?)채용대시보드\\.html$&quot;, Pattern.CASE_INSENSITIVE);</code></pre>
<p>두 가지가 마음에 든다.</p>
<p><strong>첫째, 규칙 밖 이름을 목록에서 빼지 않는다.</strong> 파싱 실패를 &quot;이 파일은 없는 것&quot;으로 처리하면, 인사팀이 파일명을 조금 다르게 올린 날 화면에서 그 날짜가 조용히 사라진다. 사용자는 대시보드가 안 올라온 줄 안다. 그래서 기준일 없음 + 종류 &quot;기타&quot;로 목록에 남긴다. <strong>애매한 후보를 코드가 대신 버리지 않는다 — 빼는 건 사용자가 한다.</strong></p>
<p><strong>둘째, <code>id</code>가 정렬에 쓸 수 있는 값이었다.</strong> 올린 시각이 없어도 <code>id</code>가 스노플레이크라 <strong>id 오름차순 = 업로드 순</strong>이다. 첨부 60개로 확인했다(파일명 날짜와 단조 증가로 일치). 파일명 파싱이 실패한 파일도 이걸로는 순서가 잡힌다.</p>
<p>그리고 화면 주소에 남기는 값을 <strong>파일명이 아니라 <code>id</code></strong> 로 골랐다.</p>
<pre><code>/recruit?file=&lt;fileId&gt;</code></pre><p>이유는 실측이다. <strong>같은 이름이 두 번 올라온 날이 실제로 있었다.</strong> 파일명을 키로 쓰면 링크가 두 파일 중 하나를 무작위로 가리킨다.</p>
<hr>
<h2 id="8-목록-밖-첨부는-열지-않는다">8. 목록 밖 첨부는 열지 않는다</h2>
<p><code>GET /console/v1/recruit/dashboards/{fileId}</code>는 fileId를 그대로 받아 두레이에서 내려받는다. 그러면 사용자가 <strong>아무 fileId나 넣어</strong> 그 업무의 다른 첨부를 우리 서버 토큰으로 받아 볼 수 있는지 물어야 한다.</p>
<pre><code class="language-java">String fileName = files(access, task).stream()
    .filter(file -&gt; fileId.equals(file.path(&quot;id&quot;).asString(&quot;&quot;)))
    .map(file -&gt; file.path(&quot;name&quot;).asString(&quot;&quot;))
    .findFirst().orElse(null);

if (fileName == null) {
    log.info(&quot;업무에 없는 첨부라 내려받지 않습니다. fileId={}&quot;, fileId);
    return Optional.empty();      // → 404
}</code></pre>
<p>목록에 있는 id만 통과한다. 그리고 앞서 말한 대로 목록 자체가 <strong>사용자 개인 토큰</strong>으로 읽힌 것이므로, 그 업무를 못 보는 사람에게는 목록이 비어 있고 따라서 어떤 id도 통과하지 않는다.</p>
<p><strong>id를 그대로 하위 시스템에 넘기는 엔드포인트는 &quot;그 id가 이 사용자의 목록에 있었나&quot;를 먼저 물어야 한다.</strong> 목록 API와 상세 API가 따로 있을 때 상세만 검사를 빼먹는 게 IDOR의 전형이다.</p>
<hr>
<h2 id="9-성능--느린-건-내려받기가-아니라-그리기였다">9. 성능 — 느린 건 내려받기가 아니라 그리기였다</h2>
<p>측정값. 목록 6.6KB·0.9초. 첨부 1개 내려받기 0.7초. 그런데 전환 체감이 훨씬 나빴다.</p>
<p>원인은 <strong>렌더링</strong>이다. 원본이 지원자 5,724행을 자기 스크립트로 그린다. 내려받기가 끝난 뒤가 진짜 대기였다.</p>
<pre><code class="language-tsx">/**
 * 동시에 살려 둘 프레임 수(보고 있는 것 + 미리 그려 둔 것).
 *
 * &lt;p&gt;&lt;b&gt;왜 3인가.&lt;/b&gt; 느린 건 내려받기(0.7초)가 아니라 &lt;b&gt;그리기&lt;/b&gt;다 — 원본이 지원자
 * 5,724행을 자기 스크립트로 그린다. 그래서 같은 날짜의 3종(통합·그룹별·내부)을 미리 그려
 * 두고, 전환할 때는 보여 주기만 한다. 조직장이 실제로 오가는 최소 단위가 그 3종이다.
 *
 * &lt;p&gt;이 수를 늘리면 그만큼 무거운 문서를 동시에 들고 있게 된다(하나에 1.4~2.8MB + 그린 결과).
 * 늘릴 일이 생기면 메모리와 전환 체감을 실제 브라우저에서 같이 확인할 것.
 */
const PRERENDER_LIMIT = 3;</code></pre>
<p>두 겹으로 처리했다.</p>
<ol>
<li><strong>바이트 캐시</strong> — 받은 바이트를 브라우저 메모리에 들고 blob URL로 iframe에 건다. 다시 열 때 내려받기를 건너뛴다.</li>
<li><strong>미리 그리기</strong> — 같은 날짜의 3종을 보이지 않게 띄워 둔다. 전환이 곧바로 된다.</li>
</ol>
<pre><code class="language-tsx">// 고른 파일을 살아 있는 목록 맨 앞으로. 넘치면 가장 오래 안 본 것을 버린다(그건 바이트
// 캐시에 남아 있어 다시 열어도 내려받기는 없다).
useEffect(() =&gt; {
  setLive((prev) =&gt; {
    const rest = prev.filter((f) =&gt; f.fileId !== file.fileId);
    return [file, ...rest].slice(0, PRERENDER_LIMIT);
  });
}, [file]);</code></pre>
<p>여기서 §2와 충돌하는 것처럼 보인다. &quot;캐시하지 않는다&quot;고 했는데 브라우저 메모리에 들고 있다. 구분이 있다.</p>
<ul>
<li><strong>디스크·서버에 남기지 않는다</strong> — <code>no-store</code>, 서버 캐시 없음, DB 없음</li>
<li><strong>탭이 열려 있는 동안 메모리에는 있다</strong> — 어차피 화면에 그려져 있는 내용이다</li>
</ul>
<p>이미 렌더링된 문서와 같은 수명의 메모리는 새로운 노출면이 아니다. 탭을 닫으면 사라진다. 캐시 정책을 &quot;캐시한다/안 한다&quot;의 이진으로 두지 말고 <strong>어느 저장 계층까지 허용되나</strong>로 두면 이 구분이 자연스럽게 나온다.</p>
<p><code>PRERENDER_LIMIT = 3</code>에 근거를 적어 둔 것도 의도적이다. 이런 상수는 아무 근거 없이 5나 10으로 바뀌기 쉽다. 하나에 2.8MB짜리 문서라 그 변경의 비용이 크다.</p>
<hr>
<h2 id="10-검증에서-밟은-마지막-함정--스크린샷이-iframe을-못-잡는다">10. 검증에서 밟은 마지막 함정 — 스크린샷이 iframe을 못 잡는다</h2>
<p>이건 코드 문제가 아니라 검증 방법 문제였다.</p>
<p>브라우저 자동화로 스크린샷을 찍어 확인하고 있었는데, <strong>iframe 내용이 안 찍힌 스크린샷을 보고 &quot;빈 화면&quot;으로 판단</strong>했다. 실제로는 잘 뜨고 있었다. 캡처 방식에 따라 크로스 오리진 프레임(그리고 opaque origin 프레임)의 내용이 비어 나올 수 있다.</p>
<p>이 기능에서는 특히 위험하다. <strong>§4의 진짜 빈 프레임 버그와 캡처 한계가 똑같이 보인다.</strong> 한 번은 없는 버그를 쫓았다.</p>
<p>그래서 iframe 격리를 다루는 작업의 검증 규칙을 이렇게 정했다.</p>
<ol>
<li>스크린샷으로 &quot;떴다/안 떴다&quot;를 판정하지 않는다</li>
<li>프레임 안 DOM을 직접 확인하거나(가능한 경우), 콘솔의 CSP 위반 로그를 읽는다</li>
<li>같은 URL을 <strong>새 탭으로 직접 열어</strong> 비교한다 — §4 버그는 이 두 경로의 결과가 다르다는 게 결정적 증거였다</li>
</ol>
<p>3번이 실제로 원인을 갈랐다. &quot;주소창으로 열면 되는데 iframe에서만 안 된다&quot;가 곧 &quot;문서 자체가 아니라 프레이밍 검사가 막고 있다&quot;는 뜻이고, 거기서 헤더 상호작용으로 좁혀졌다.</p>
<hr>
<h2 id="11-정리">11. 정리</h2>
<p><strong>1) 남의 데이터를 재집계하면 그 도메인의 정확성을 떠안는다.</strong> 원본이 있으면 그대로 보여 주는 쪽을 먼저 검토하라. 두 번째 진실이 생기는 날 답할 사람은 우리다.</p>
<p><strong>2) 캐시 경계를 &quot;얼마나 자주 바뀌나&quot;가 아니라 &quot;어디에 남아도 되나&quot;로 그어라.</strong> 목록과 내용이 같은 API에서 와도 등급이 다르다.</p>
<p><strong>3) 자격을 위임받아 부르면 인가가 공짜다.</strong> 서비스 계정으로 바꾸는 순간 권한 정의를 우리가 떠안는다.</p>
<p><strong>4) <code>allow-scripts</code> + <code>allow-same-origin</code>은 sandbox 해제와 거의 같다.</strong></p>
<p><strong>5) <code>CSP: sandbox</code>로 opaque origin이 된 문서에 <code>X-Frame-Options: SAMEORIGIN</code>을 붙이면 자기 화면에서도 빈 프레임이 된다.</strong> 삽입 제한은 <code>frame-ancestors</code>로 걸어라 — 그건 담는 쪽의 origin을 본다.</p>
<p><strong>6) 전역 보안 기본값에서 한 경로만 빼려면, 끄고 나서 &quot;그 경로를 제외한 전부&quot;에 다시 걸어라.</strong> 새 경로가 자동으로 보호받는다.</p>
<p><strong>7) 자동 리다이렉트를 끄고 손으로 따라갈 때는 <code>Location</code>의 호스트를 화이트리스트로 검사하라.</strong> 안 하면 원격 응답이 우리 자격의 목적지를 정한다.</p>
<p><strong>8) 파싱 실패를 &quot;없는 것&quot;으로 처리하지 마라.</strong> 목록에서 조용히 사라지면 사용자는 원본이 안 올라온 줄 안다.</p>
<p><strong>9) 링크에 남길 키는 이름이 아니라 id로.</strong> 같은 이름이 두 번 올라오는 날이 실제로 있다.</p>
<p><strong>10) 목록 API와 상세 API가 따로 있으면 상세에서 &quot;그 id가 이 사용자의 목록에 있었나&quot;를 물어라.</strong></p>
<p><strong>11) 병목을 재고 나서 최적화하라.</strong> 여기선 내려받기(0.7초)가 아니라 렌더링이었고, 그래서 답이 캐시가 아니라 미리 그리기였다.</p>
<p><strong>12) iframe 격리를 다룰 때 스크린샷으로 &quot;떴다&quot;를 판정하지 마라.</strong> 캡처 한계와 진짜 빈 프레임이 똑같이 보인다. 새 탭 직접 열기와 비교하는 게 결정적 증거다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MCP 서버에 브라우저 로그인을 붙이기까지 — 함정을 밟은 순서대로]]></title>
            <link>https://velog.io/@gitit-sehyeon/MCP-%EC%84%9C%EB%B2%84%EC%97%90-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EB%B6%99%EC%9D%B4%EA%B8%B0%EA%B9%8C%EC%A7%80-%ED%95%A8%EC%A0%95%EC%9D%84-%EB%B0%9F%EC%9D%80-%EC%88%9C%EC%84%9C%EB%8C%80%EB%A1%9C</link>
            <guid>https://velog.io/@gitit-sehyeon/MCP-%EC%84%9C%EB%B2%84%EC%97%90-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EB%B6%99%EC%9D%B4%EA%B8%B0%EA%B9%8C%EC%A7%80-%ED%95%A8%EC%A0%95%EC%9D%84-%EB%B0%9F%EC%9D%80-%EC%88%9C%EC%84%9C%EB%8C%80%EB%A1%9C</guid>
            <pubDate>Mon, 24 Aug 2026 01:26:31 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/634f79bd-a75b-4942-823b-844be917abc7/image.png" alt=""></p>
<blockquote>
<p>사내 코드라 호스트·이슈어·사번·클라이언트 id 는 바꿨다. 구조와 실측값은 그대로다.
앱 경로 prefix 는 <code>/myapp</code> 으로 통일했다.</p>
</blockquote>
<hr>
<h2 id="0-출발점--되는데-손이-간다">0. 출발점 — 되는데 손이 간다</h2>
<p>사내 팀장 지원 도구를 MCP 서버로 만들어서, Claude Code가 우리 앱에 붙어 주간 보고 원재료를 도구로 읽게 했다. 서버 쪽은 이미 종단까지 돌고 있었다 — <code>initialize</code> → <code>tools/list</code> → 도구 호출까지 전부 200.</p>
<p>남은 벽은 하나였다. <strong>JWT 확보가 손작업이라는 것.</strong> Keycloak의 <code>account-console</code>로 PKCE를 브라우저에서 손으로 돌려 code를 받고, 그 토큰을 헤더에 박아 둔다. realm 기본 수명이 access 8시간이라 하루에 한 번은 이걸 다시 해야 한다.</p>
<p>목표는 이 명령 한 줄이었다.</p>
<pre><code class="language-bash">claude mcp add --transport http myapp https://alpha-support.example.com/myapp/mcp \
  --client-id myapp-mcp-dev --callback-port 26500</code></pre>
<p>여기까지 오는 데 함정을 여덟 개 밟았다. 이 글은 <strong>밟은 순서대로</strong> 쓴다. 각 단계에 그걸 어떻게 알았는지(스펙 읽기 / 배포 전 curl / 코드 읽기 / 배포 후 curl)를 붙여 뒀다. 마지막에 그 분포를 세는 것이 이 글의 결론이다.</p>
<hr>
<h2 id="1단계--스펙을-읽는다-클라이언트는-인증서버가-어디인지-모른다">1단계 — 스펙을 읽는다: 클라이언트는 인증서버가 어디인지 모른다</h2>
<p><code>--client-id</code>를 줬으니 될 것 같았다. 안 된다. 클라이언트는 <strong>어디서 로그인해야 하는지 스스로 알지 못한다.</strong> 401을 받고 거기 실린 단서를 따라가는 것이 전부다(RFC 9728, OAuth 2.0 Protected Resource Metadata).</p>
<pre><code>1. POST /mcp  (토큰 없음)
   ← 401
     WWW-Authenticate: Bearer resource_metadata=&quot;https://…/myapp/.well-known/oauth-protected-resource&quot;

2. GET  그 절대 URL
   ← 200 application/json
     {
       &quot;resource&quot;: &quot;https://alpha-support.example.com/myapp/mcp&quot;,
       &quot;authorization_servers&quot;: [&quot;https://sign-in.example.com/auth/realms/corp&quot;],
       …
     }

3. authorization_servers[0] 의 OIDC discovery → PKCE 로 브라우저 로그인
4. POST /mcp  (Authorization: Bearer …)</code></pre><p>그러니까 서버가 할 일은 두 개다. <strong>401에 챌린지 헤더를 실는 것</strong>, 그리고 <strong>그 헤더가 가리키는 문서를 서빙하는 것.</strong></p>
<p>여기까지가 스펙 읽기로 나온 설계다. 그리고 이 시점에 이미 함정이 하나 숨어 있었다(4단계에서 나온다).</p>
<hr>
<h2 id="2단계--배포-전-curl-표준-위치가-우리-것이-아니었다">2단계 — 배포 전 curl: 표준 위치가 우리 것이 아니었다</h2>
<p>RFC 9728은 이 문서를 <strong>host 루트</strong>에 두고 리소스 경로를 뒤에 끼우라고 한다.</p>
<pre><code>https://alpha-support.example.com/.well-known/oauth-protected-resource/myapp/mcp</code></pre><p>우리 앱은 <strong>공유 host의 <code>/myapp</code> prefix만 소유</strong>한다. 루트는 다른 상품과 함께 게이트웨이가 분기하는 자리라 파일을 놓을 수 없다. 알파에 대고 열어 봤다 — 루트의 <code>.well-known/oauth-protected-resource</code>는 <strong>404</strong>.</p>
<p>한 시간쯤 &quot;인프라 팀에 루트 경로를 열어 달라고 해야 하나&quot;를 고민했다. 그러다 스펙을 다시 읽었고, 구멍이 스펙 안에 있었다. <strong><code>resource_metadata</code>는 절대 URL이고, 클라이언트는 표준 경로를 유추하기 전에 그 URL을 먼저 쓴다.</strong></p>
<pre><code class="language-java">/**
 * RFC 9728 protected resource metadata 문서의 &lt;b&gt;경로&lt;/b&gt;.
 *
 * &lt;p&gt;표준은 이 문서를 &lt;b&gt;host 루트&lt;/b&gt;에 경로를 끼워 두라고 한다. 그런데 이 앱은 공유 host 의
 * {@code /myapp} prefix 만 소유한다 — 루트는 다른 상품과 함께 게이트웨이가 분기하는 자리라
 * 우리가 파일을 놓을 수 없다(실측: 루트는 404).
 *
 * &lt;p&gt;대신 {@code WWW-Authenticate} 의 {@code resource_metadata} 는 &lt;b&gt;절대 URL&lt;/b&gt;이고,
 * 클라이언트는 표준 경로를 유추하기 전에 그 URL 을 먼저 쓴다. 그래서 문서를 우리 prefix
 * 아래에 두고 헤더로 가리킨다 — 공유 host 루트를 한 줄도 건드리지 않고 끝난다.
 */
public static final String RESOURCE_METADATA_PATH = &quot;/.well-known/oauth-protected-resource&quot;;</code></pre>
<p><strong>멀티테넌트 게이트웨이 뒤에서 표준 well-known 경로가 막히면, 인프라 요청서를 쓰기 전에 스펙에서 간접 지시를 먼저 찾아라.</strong> <code>.well-known</code>을 요구하는 스펙 중 상당수가 우회로를 함께 정의해 둔다 — ACME의 <code>Link</code> 헤더, OAuth의 <code>resource_metadata</code>가 그런 식이다.</p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 배포 전 curl(알파 루트 404) + 스펙 재독.</p>
</blockquote>
<hr>
<h2 id="3단계--배포-전-curl-그-자리에-이미-뭔가가-200을-주고-있었다">3단계 — 배포 전 curl: 그 자리에 이미 뭔가가 200을 주고 있었다</h2>
<p>문서를 놓을 자리를 정했으니 그 경로가 비어 있는지 확인했다. 안 비어 있었다.</p>
<p>prefix 아래 <code>.well-known/oauth-protected-resource</code>를 열었더니 <strong>200 text/html</strong>이 왔다. 프론트 nginx의 SPA fallback(<code>try_files … /index.html</code>)에 먹힌 것이다.</p>
<p><strong>404보다 나쁘다.</strong> 404면 클라이언트가 &quot;없다&quot;로 판단하고 폴백한다. 200 text/html이면 JSON 파서가 터진다. 그리고 이건 우리가 문서를 붙이지 않았더라도 이미 그 상태였다 — 클라이언트가 표준 경로를 유추하다 여기 닿으면 그때부터 진단이 어려워진다.</p>
<pre><code class="language-nginx"># MCP 자동 로그인의 discovery 문서(RFC 9728). 이게 SPA fallback 으로 넘어가면 클라이언트가
# JSON 대신 index.html 을 받아 파싱에 실패한다 — 알파에서 실제로 그 상태였다(200 text/html).
#
# 표준 위치는 host 루트지만 이 host 는 공유라 루트가 우리 것이 아니다. BE 가 401 의
# WWW-Authenticate 로 이 주소를 절대 URL 로 알려주므로 클라이언트는 표준 위치를 유추하지 않는다.
#
# 인증 없이 닿아야 한다 — 로그인하는 방법을 알려주는 문서다(BE 도 permitAll).
location = /myapp/.well-known/oauth-protected-resource {
  proxy_pass ${BACKEND_URL}/.well-known/oauth-protected-resource;
  proxy_http_version 1.1;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
}</code></pre>
<p><code>location =</code> 정확 매칭이다. prefix 매칭으로 두면 하위 경로까지 BE로 새고, 그러면 표준이 정한 하위 경로 형태(<code>…/oauth-protected-resource/myapp/mcp</code>)가 BE의 다른 라우팅과 부딪힐 수 있다.</p>
<p>그리고 이 문서는 <strong>인증 예외</strong>다. 로그인하는 방법을 알려주는 문서에 로그인을 요구하면 닭-달걀이다. 담긴 것은 설정값(issuer·resource URL)뿐이고 사용자 데이터가 없다.</p>
<p><strong>SPA를 서빙하는 nginx 뒤에 기계가 읽는 경로를 두려면 fallback보다 먼저 잡아야 한다.</strong> SPA fallback은 &quot;없는 경로를 앱으로 넘긴다&quot;인데, 기계용 경로는 없으면 없다고 말해야 한다. <code>robots.txt</code>·<code>.well-known/*</code>·<code>/healthz</code> 다 같은 문제를 갖는다.</p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 배포 전 curl(200 text/html).</p>
</blockquote>
<hr>
<h2 id="4단계--코드-읽기-같은-파일-옆-블록에-조용한-구멍이-적혀-있었다">4단계 — 코드 읽기: 같은 파일 옆 블록에 조용한 구멍이 적혀 있었다</h2>
<p>nginx 설정을 고치러 그 파일을 열었더니, 바로 위 블록에 이런 주석이 있었다.</p>
<pre><code class="language-nginx"># MCP 직접 API(BFF 미경유).
#
# prefix 를 반드시 떼야 한다 — BE 의 McpAuthenticationFilter 는 `/mcp` 로 시작하는 URI 만
# 인증·인가 대상으로 보므로, prefix 를 달고 넘기면 필터가 안 걸려 **인증 없이 통과**한다
# (조용한 구멍).
#
# `=` 정확 매칭이다. Streamable HTTP 는 단일 엔드포인트 `/mcp` 고 trailing slash 가 없다.
location = /myapp/mcp { … }</code></pre>
<p>MCP 자체는 이 작업 전에 이미 붙어 있었으니 새로 생긴 버그는 아니다. 그런데 <strong>읽고 나니 이 작업의 전제가 바뀌었다.</strong></p>
<p>인증 필터가 <strong>URI 접두사로 대상을 고른다</strong>면, 그 URI를 만드는 프록시 설정이 <strong>인증의 일부</strong>다. 그리고 그 설정은 코드 리뷰의 눈이 잘 안 가는 파일에 있다. 지금 나는 그 파일에 블록을 하나 더 추가하는 중이었다.</p>
<p>우리 체인은 <code>Deny by default</code>라 최악은 아니다.</p>
<pre><code class="language-java">.requestMatchers(&quot;/mcp/**&quot;).authenticated()
.anyRequest().authenticated()</code></pre>
<p>그래도 이 주석이 그 파일에 남아 있는 게 중요하다. 다음에 누가 &quot;prefix 떼는 게 지저분하네&quot; 하고 정리하려 들 때 걸리라고 적어 둔 것이다. <strong>경로 매칭으로 보안 경계를 정하는 필터를 쓸 때는, 그 경로가 어디서 만들어지는지 같이 봐야 한다.</strong></p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 코드(설정) 읽기. 고치러 들어간 파일에서 덤으로.</p>
</blockquote>
<hr>
<h2 id="5단계--설계-광고할-주소를-요청에서-유추하지-않는다">5단계 — 설계: 광고할 주소를 요청에서 유추하지 않는다</h2>
<p>이제 문서 내용을 채운다. <code>resource</code> 값을 뭘로 하나.</p>
<p>nginx가 prefix를 떼고 BE로 넘긴다(4단계에서 그게 인증의 전제라는 걸 확인했다). 그러니까 <strong>BE가 보는 요청 URI에는 <code>/myapp</code>이 없다.</strong> 요청에서 유추하면 클라이언트에게 닿지 않는 주소를 알려주게 된다.</p>
<p>그래서 설정으로 받는다.</p>
<pre><code class="language-java">/**
 * &lt;b&gt;외부에서 보이는&lt;/b&gt; 이 앱의 base URL — 게이트웨이 prefix 를 포함하고 끝 슬래시는 없다
 * (예: {@code https://alpha-support.example.com/myapp}).
 *
 * &lt;p&gt;요청에서 유추하지 않는다. nginx 가 prefix 를 떼고 BE 로 넘기므로 BE 가 보는 URI 에는
 * prefix 가 없고, 유추하면 MCP 클라이언트에게 &lt;b&gt;닿지 않는 주소&lt;/b&gt;를 알려주게 된다.
 * 비어 있으면 discovery 를 아예 광고하지 않는다(틀린 주소를 주는 것보다 낫다).
 */
private String publicBaseUrl;</code></pre>
<p><code>X-Forwarded-*</code>를 신뢰하는 방법도 있다. 안 골랐다. 게이트웨이 두 겹(Envoy → nginx)을 지나고 그중 하나는 우리가 소유하지 않는다. <strong>우리가 통제하지 못하는 헤더로 우리가 광고할 주소를 만들지 않는다.</strong></p>
<p>그리고 마지막 문장이 이 단계의 판단이다. <strong>설정이 비면 광고를 아예 하지 않는다.</strong></p>
<pre><code class="language-java">private void rejectUnauthorized(HttpServletResponse response, boolean tokenPresent) {
    String metadataUrl = properties.resourceMetadataUrl();
    if (metadataUrl != null) {          // 설정이 없으면 헤더를 아예 안 붙인다
        …
    }
    reject(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;unauthorized&quot;);
}</code></pre>
<p><strong>틀린 주소를 주는 쪽이 아무것도 안 주는 쪽보다 나쁘다.</strong> 아무것도 안 주면 클라이언트가 &quot;이 서버는 자동 로그인을 지원하지 않는다&quot;로 판단하고 수동 헤더로 폴백한다 — 원래 상태다. 틀린 주소를 주면 실패하고, 실패 원인이 우리 값이라는 걸 아무도 모른다.</p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 설계 판단(4단계에서 확인한 prefix strip 사실로부터).</p>
</blockquote>
<hr>
<h2 id="6단계--스펙-읽기-401-하나에-두-가지-뜻이-있었다">6단계 — 스펙 읽기: 401 하나에 두 가지 뜻이 있었다</h2>
<p>챌린지 헤더를 쓰다가 RFC 6750에서 걸렸다. 무조건 <code>error=&quot;invalid_token&quot;</code>을 붙이려던 걸 고쳤다.</p>
<pre><code class="language-java">/**
 * 401 + &lt;b&gt;{@code WWW-Authenticate} 챌린지&lt;/b&gt;. 이 헤더가 MCP 클라이언트의 자동 로그인
 * 시작점이다(RFC 9728 §5.1).
 *
 * &lt;p&gt;토큰을 실어 보냈는데 검증에 실패한 경우에만 {@code error=&quot;invalid_token&quot;} 을 붙인다 —
 * 토큰이 아예 없는 첫 요청은 오류가 아니라 &quot;아직 로그인 안 했다&quot;이고, RFC 6750 이 그 구분을
 * 요구한다. 클라이언트도 이 구분으로 &quot;다시 발급&quot;과 &quot;처음 발급&quot;을 가른다.
 *
 * @param tokenPresent 요청에 Bearer 토큰이 실려 있었는가
 */
private void rejectUnauthorized(HttpServletResponse response, boolean tokenPresent)
        throws IOException {
    String metadataUrl = properties.resourceMetadataUrl();
    if (metadataUrl != null) {
        StringBuilder challenge = new StringBuilder(&quot;Bearer&quot;);
        if (tokenPresent) {
            challenge.append(&quot; error=\&quot;invalid_token\&quot;,&quot;);
        }
        challenge.append(&quot; resource_metadata=\&quot;&quot;).append(metadataUrl).append(&#39;&quot;&#39;);
        response.setHeader(&quot;WWW-Authenticate&quot;, challenge.toString());
    }
    reject(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;unauthorized&quot;);
}</code></pre>
<p>이걸 안 고쳤으면 <strong>첫 로그인이 안 됐을 것이다.</strong> 클라이언트는 첫 요청에도 &quot;토큰이 상했다&quot;로 판단해 갱신 흐름을 타려 하고, 갱신할 토큰이 없어 실패한다.</p>
<p>401 하나에 두 가지 뜻이 있다. <strong>&quot;너 누군지 모르겠다&quot;</strong> 와 <strong>&quot;네가 준 게 안 맞는다&quot;</strong>. 클라이언트가 자동 재시도를 하는 프로토콜이라면 이 구분이 곧 재시도 정책이다.</p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 스펙 읽기(RFC 6750). 배포 전에 잡은 유일한 논리 버그다.</p>
</blockquote>
<hr>
<h2 id="7단계--사전-실측이-살렸다-aud-검사를-켜려다-껐다">7단계 — 사전 실측이 살렸다: <code>aud</code> 검사를 켜려다 껐다</h2>
<p>교과서는 audience를 검사하라고 한다. 켜려고 필드를 채우려던 참에, 며칠 전 손으로 받아 둔 토큰을 디코드해 본 기록이 떠올랐다. <code>aud</code>가 <code>account</code>였다.</p>
<pre><code class="language-java">/**
 * 검증할 audience. 비어있으면 검사 안 함.
 *
 * &lt;p&gt;&lt;b&gt;사내 Keycloak 에서는 비워 둬야 한다.&lt;/b&gt; Keycloak access token 의 {@code aud} 는 보통
 * {@code account} 라서, 여기에 우리 resource 를 적으면 정상 토큰이 전부 401 이 된다(RFC 8707
 * resource indicator 로 aud 를 우리 쪽으로 바꾸려면 Keycloak 설정이 함께 필요하다).
 */
private String audience;</code></pre>
<p><strong>이유를 코드에 못 박아 둔 게 이 단계의 산출물이다.</strong> 보안 검토를 받으면 &quot;audience 검증이 없다&quot;가 반드시 올라온다. 그때 이유가 없으면 누군가 값을 채워 넣고 다음 배포가 전부 401이 된다.</p>
<p>제대로 하려면 RFC 8707 resource indicator로 발급 시 <code>aud</code>를 우리 resource로 바꿔야 하고, 그건 realm 담당자 몫이다. <strong>우리 쪽만 고쳐서 되는 일이 아니면 그 사실을 코드에 적어 둔다.</strong></p>
<h3 id="그리고-설정을-정리하며-한-곳으로-모은-것">그리고 설정을 정리하며 한 곳으로 모은 것</h3>
<p>이 무렵 issuer 정규화를 한 곳으로 합쳤다.</p>
<pre><code class="language-java">/**
 * 끝 슬래시를 없앤 issuer. 설정이 비어 있으면 {@code null}.
 *
 * &lt;p&gt;정규화가 여기 한 곳에 있어야 한다 — 토큰 검증({@code iss} 비교·JWKS 주소)과 discovery
 * 문서의 {@code authorization_servers} 가 &lt;b&gt;같은 문자열&lt;/b&gt;이어야 하고, 두 곳에서 각자
 * 다듬으면 설정에 슬래시가 하나 붙은 날 조용히 갈린다.
 */
public String normalizedIssuer() {
    return trimTrailingSlash(issuer);
}

/** 이 MCP 서버의 canonical resource 식별자. */
public String resource() {
    String base = publicBaseUrl();
    return base == null ? null : base + &quot;/mcp&quot;;
}

/** protected resource metadata 문서의 절대 URL({@code WWW-Authenticate} 에 실린다). */
public String resourceMetadataUrl() {
    String base = publicBaseUrl();
    return base == null ? null : base + RESOURCE_METADATA_PATH;
}</code></pre>
<p><code>iss</code> 비교는 문자열 완전 일치다. discovery가 광고한 issuer와 토큰 검증이 기대하는 issuer가 슬래시 하나 차이로 갈리면 <strong>&quot;로그인은 됐는데 401&quot;</strong> 이 된다. 제일 진단하기 어려운 상태다 — 브라우저 로그인 화면이 정상적으로 뜨고, 코드도 받고, 토큰도 받고, 그다음에 막힌다.</p>
<p><strong>여러 곳에서 비교되는 식별자는 그것을 만드는 함수가 하나여야 한다.</strong></p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 며칠 전 손으로 토큰을 받아 본 기록(사전 실측). 그게 없었으면 이건 배포 후에 전부 401로 만나는 함정이었다.</p>
</blockquote>
<hr>
<h2 id="8단계--배포-그리고-나간-문서가-우리-것이-아니었다">8단계 — 배포. 그리고 나간 문서가 우리 것이 아니었다</h2>
<p>여기까지가 1차 배포다. 단위 테스트는 통과했다 — discovery 문서의 필드 이름(snake_case)·주소 조립·설정 미비 시 404·401 챌린지의 유무와 <code>invalid_token</code> 구분까지.</p>
<p>알파에 올리고 curl로 문서를 열었다.</p>
<pre><code class="language-json">{&quot;resource&quot;:&quot;http://alpha-support.example.com&quot;,
 &quot;bearer_methods_supported&quot;:[&quot;header&quot;],
 &quot;tls_client_certificate_bound_access_tokens&quot;:true}</code></pre>
<p><strong>우리 문서가 아니었다.</strong></p>
<p>Spring Security 7.1(Boot 4.1)의 <code>OAuth2ProtectedResourceMetadataFilter</code>가 <code>/.well-known/oauth-protected-resource</code>를 <strong>이미 서빙하고 있었다.</strong> <code>oauth2ResourceServer</code>를 켜면 기본으로 붙는다. 우리가 같은 경로에 만든 <code>@RestController</code>는 <strong>필터가 먼저 응답해서 아예 닿지 않았다.</strong></p>
<p>그 기본 문서가 왜 못 쓰는지도 이 응답에 다 나와 있었다.</p>
<ol>
<li><strong><code>resource</code>의 스킴이 <code>http</code></strong> — 게이트웨이를 지나며 스킴이 바뀐다</li>
<li><strong>경로가 없다</strong> — nginx가 prefix를 떼고 넘긴다(5단계에서 예측한 그대로다)</li>
<li><strong><code>authorization_servers</code>가 아예 없다</strong> — 갈 곳이 없다</li>
<li><strong><code>tls_client_certificate_bound_access_tokens</code>가 <code>true</code></strong> — 우리는 mTLS 바인딩을 하지 않는다</li>
</ol>
<p>1·2번이 특히 조용한 실패다. 클라이언트는 <strong>자기가 붙은 주소와 <code>resource</code> 값을 맞춰 본다.</strong> 어긋나면 로그인을 시작하지 않는다. &quot;resource mismatch&quot;라고 친절히 나오지도 않는다.</p>
<p>5단계에서 &quot;요청에서 유추하면 닿지 않는 주소가 된다&quot;고 판단한 게 맞았다는 걸, 프레임워크 기본값이 실물로 증명해 준 셈이다.</p>
<h3 id="고침-서빙은-프레임워크에-맡기고-내용만-채운다">고침: 서빙은 프레임워크에 맡기고 내용만 채운다</h3>
<pre><code class="language-java">.oauth2ResourceServer(oauth2 -&gt; oauth2
    .jwt(jwt -&gt; jwt.jwtAuthenticationConverter(keycloakJwtAuthenticationConverter()))
    // MCP 자동 로그인의 discovery 문서. 서빙은 프레임워크의
    // OAuth2ProtectedResourceMetadataFilter 가 하고(oauth2ResourceServer 를 켜면 기본으로
    // 붙는다) 우리는 내용만 채운다 — 같은 경로에 컨트롤러를 만들면 필터가 먼저 응답해서
    // 컨트롤러에 닿지 않는다(알파 실측).
    .protectedResourceMetadata(metadata -&gt;
        metadata.protectedResourceMetadataCustomizer(mcpMetadata)))</code></pre>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class McpProtectedResourceMetadataCustomizer
    implements Consumer&lt;OAuth2ProtectedResourceMetadata.Builder&gt; {

    @Override
    public void accept(OAuth2ProtectedResourceMetadata.Builder builder) {
        String resource = properties.resource();
        String issuer = properties.normalizedIssuer();

        // 설정이 없으면 기본값(유추한 주소)을 그대로 둔다 — 우리가 반쯤 채워 놓으면 &quot;우리 값인데
        // 틀렸다&quot; 가 되어 원인을 찾기 더 어렵다. 어차피 그 상태에서는 401 에 챌린지도 붙지 않는다.
        if (resource != null) {
            builder.resource(resource);
        }
        if (issuer != null) {
            builder.authorizationServer(issuer);
        }
        …
    }
}</code></pre>
<p>컨트롤러를 지우고 커스터마이저로 옮긴 것이 전부지만, <strong>여기서 배운 건 프레임워크 지식이 아니라 검증 방법이다.</strong> 문서에 이렇게 적어 뒀다.</p>
<blockquote>
<p><strong>단위 테스트만으로는 이 함정을 못 잡는다</strong> — 컨트롤러를 직접 부르는 테스트는 통과했고, 가려진 것은 알파에 올려 <code>curl</code>로 문서를 열어 봐서 알았다.</p>
</blockquote>
<p>컨트롤러 단위 테스트는 컨트롤러가 <strong>호출된다는 전제</strong>를 검증하지 않는다. 필터 체인·프록시·라우팅이 그 전제를 깨는데, 그건 그 계층을 실제로 통과시켜 봐야 보인다.</p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 배포 후 curl. 함정 두 개(필터 가림 + 기본값 유추)를 한 응답에서 같이 봤다.</p>
</blockquote>
<hr>
<h2 id="9단계--덮으면서-하지-않는-일을-광고하지-마라">9단계 — 덮으면서: 하지 않는 일을 광고하지 마라</h2>
<p>기본값을 덮는 코드를 쓰다가 두 가지를 더 정리했다.</p>
<pre><code class="language-java">// 기본값에 무엇이 들어 있든 우리가 지원하는 것으로 덮는다(누적 방지).
builder.bearerMethods(methods -&gt; {
    methods.clear();
    methods.add(&quot;header&quot;);
});
builder.scopes(scopes -&gt; {
    scopes.clear();
    scopes.addAll(SCOPES);
});
builder.resourceName(RESOURCE_NAME);
// 기본값이 true 였다(알파 실측). 우리는 mTLS 로 토큰을 바인딩하지 않으므로 사실이 아니다 —
// 클라이언트가 이 광고를 믿고 인증서 바인딩을 시도할 이유를 만들지 않는다.
builder.tlsClientCertificateBoundAccessTokens(false);</code></pre>
<p><strong>첫째, <code>clear()</code>를 빼먹으면 프레임워크 기본값이 남는다.</strong> 컬렉션을 채우는 빌더 API에서 흔한 실수다. <code>add</code>만 부르면 덮은 게 아니라 더한 것이다.</p>
<p><strong>둘째, scope을 최소로 줄였다.</strong></p>
<pre><code class="language-java">/**
 * 토큰을 받을 때 요청할 scope.
 *
 * &lt;p&gt;{@code profile} 이 필요하다 — 사번({@code preferred_username})을 access token 에 싣는
 * 매퍼가 Keycloak 의 {@code profile} client scope 에 들어 있고, {@code CurrentUser} 가 그
 * claim 으로 사람을 가린다. 그 외에는 광고하지 않는다: 쓰지 않는 scope 을 적으면 클라이언트가
 * 그것까지 요구하고, 클라이언트에 배정되지 않은 scope 은 Keycloak 이 {@code invalid_scope}
 * 로 거절한다.
 */
private static final List&lt;String&gt; SCOPES = List.of(&quot;openid&quot;, &quot;profile&quot;);</code></pre>
<p>discovery 문서는 <strong>광고</strong>다. 광고한 것은 클라이언트가 실제로 쓴다. 쓰지 않는 기능, 배정받지 않은 scope, 지원하지 않는 바인딩을 광고하면 그게 그대로 실패 경로가 된다. <strong>기본값을 확인 없이 내보내는 것도 광고다.</strong></p>
<blockquote>
<p><strong>어떻게 알았나:</strong> 배포 후 curl로 본 기본 문서(<code>tls_…: true</code>) + Keycloak의 <code>invalid_scope</code> 동작.</p>
</blockquote>
<hr>
<h2 id="10단계--붙인-뒤-401이-403으로-바뀌는-것이-첫-성공-신호다">10단계 — 붙인 뒤: 401이 403으로 바뀌는 것이 첫 성공 신호다</h2>
<p>끝내고도 남는 미확인을 적어 뒀다.</p>
<ul>
<li><strong>redirect URI 경로.</strong> 등록한 값은 <code>http://localhost:26500/callback</code>이다. 클라이언트가 다른 경로로 돌아오면 Keycloak이 거절하고, 거절 화면에 실제 <code>redirect_uri</code>가 찍힌다 — 다르면 그 값으로 URI 추가를 요청한다.</li>
<li><strong>refresh 활성 여부.</strong> realm 기본 수명은 access 8시간 / refresh 19시간이다. 8시간마다 로그인이 다시 뜨면 그 client에 refresh가 켜져 있는지 확인한다(발급 요청서에 Standard flow만 적었다).</li>
<li><strong>허용 목록.</strong> 토큰이 완벽해도 앱의 허용 목록에 사번이 없으면 <strong>403</strong>이다.</li>
</ul>
<p>마지막 항목이 실전에서 흔한 순서다. 인증(누구냐)과 인가(들여보내냐)가 다른 층이라, <strong>토큰이 되기 시작하면 그다음에 403이 온다.</strong> OAuth를 붙이는 작업에서 401이 403으로 바뀌는 순간이 첫 성공 신호다. 401이 계속 나오면 위 9단계 중 어딘가가 아직 안 맞는 것이고, 403이 나오면 discovery·토큰 검증은 다 통과한 것이다.</p>
<hr>
<h2 id="11-실측표--그리고-무엇이-언제-보였나">11. 실측표 — 그리고 무엇이 언제 보였나</h2>
<table>
<thead>
<tr>
<th>확인한 것</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>게이트웨이(Envoy extAuth)가 <code>/mcp</code> 를 가로채나</td>
<td><strong>아니다.</strong> 무인증 POST 가 우리 필터의 <code>401 {&quot;error&quot;:&quot;unauthorized&quot;}</code> 를 받았다 — 요청이 BE 까지 닿는다</td>
</tr>
<tr>
<td>nginx exact-match <code>= /myapp/mcp</code></td>
<td>살아 있다(그래서 위 401 이 왔다)</td>
</tr>
<tr>
<td>루트 <code>/.well-known/oauth-*</code></td>
<td><strong>404</strong> — 공유 host, 우리 것이 아니다</td>
</tr>
<tr>
<td>prefix 아래 <code>.well-known</code> (변경 전)</td>
<td><strong>200 text/html</strong> (SPA fallback) — JSON 이 아니라 오히려 나빴다</td>
</tr>
<tr>
<td>1차 배포 후 그 문서</td>
<td><strong>프레임워크 기본 문서</strong> — 우리 컨트롤러는 필터에 가려져 있었다</td>
</tr>
<tr>
<td>realm discovery</td>
<td><code>code_challenge_methods_supported: [plain, S256]</code> — PKCE 가능</td>
</tr>
<tr>
<td>토큰 수명</td>
<td>access <strong>8시간</strong> / refresh <strong>19시간</strong> (realm 기본값)</td>
</tr>
</tbody></table>
<p>밟은 함정 여덟 개를 <strong>발견 경로별로</strong> 세어 보면 이렇다.</p>
<table>
<thead>
<tr>
<th>발견 경로</th>
<th>함정</th>
</tr>
</thead>
<tbody><tr>
<td><strong>스펙 읽기</strong> (1건)</td>
<td><code>invalid_token</code>을 무조건 붙이면 첫 로그인이 안 된다</td>
</tr>
<tr>
<td><strong>배포 전 curl</strong> (2건)</td>
<td>루트 <code>.well-known</code> 404 · prefix 아래 200 text/html</td>
</tr>
<tr>
<td><strong>사전 실측 기록</strong> (1건)</td>
<td><code>aud</code>가 <code>account</code>라 검사를 켜면 전부 401</td>
</tr>
<tr>
<td><strong>코드·설정 읽기</strong> (1건)</td>
<td>prefix strip이 인증의 전제(조용한 구멍)</td>
</tr>
<tr>
<td><strong>배포 후 curl</strong> (3건)</td>
<td>프레임워크 필터가 컨트롤러를 가림 · 기본값이 요청 URL 유추(스킴·경로) · <code>tls_…: true</code></td>
</tr>
</tbody></table>
<p><strong>단위 테스트가 잡은 것은 하나도 없다.</strong> 테스트가 쓸데없었다는 뜻이 아니다 — 테스트는 우리가 <strong>이미 아는 계약</strong>(필드 이름, 주소 조립, 챌린지 유무)을 고정했고, 그건 다음에 누가 고칠 때 값을 한다. 다만 <strong>몰랐던 것은 전부 curl이 알려줬다.</strong></p>
<p>프록시·필터·게이트웨이를 지나는 계약은 그 계층을 실제로 지나 봐야 검증된다. 그래서 이 작업의 산출물 중 가장 값을 한 건 코드가 아니라 위 표다 — 다음 사람이 같은 곳을 다시 밟지 않는다.</p>
<hr>
<h2 id="12-원칙으로-정리">12. 원칙으로 정리</h2>
<p>밟은 순서와 무관하게, 옮겨 쓸 수 있는 형태로.</p>
<p><strong>1) 공유 호스트에서 <code>.well-known</code>이 막히면 스펙의 간접 지시를 찾아라.</strong> 인프라 요청서를 쓰기 전에.</p>
<p><strong>2) SPA fallback보다 먼저 기계용 경로를 잡아라.</strong> 200 text/html이 404보다 나쁘다.</p>
<p><strong>3) 경로 접두사로 보안 경계를 정하는 필터를 쓰면, 그 경로를 만드는 프록시 설정이 보안의 일부다.</strong></p>
<p><strong>4) 광고할 주소를 요청에서 유추하지 마라.</strong> 게이트웨이가 스킴을 바꾸고 prefix를 뗀다. 통제하지 못하는 헤더로 우리가 광고할 값을 만들지 않는다.</p>
<p><strong>5) 설정이 없으면 광고하지 마라.</strong> 틀린 주소가 없는 주소보다 나쁘다.</p>
<p><strong>6) 401의 두 가지 뜻을 구분하라.</strong> &quot;누군지 모른다&quot;와 &quot;네가 준 게 안 맞는다&quot;는 클라이언트의 재시도 정책을 가른다.</p>
<p><strong>7) <code>aud</code> 검사를 넣기 전에 실제 토큰의 <code>aud</code>를 확인하라.</strong> 그리고 안 넣은 이유를 코드에 적어라 — 안 적으면 다음 사람이 채워 넣고 전부 401이 된다.</p>
<p><strong>8) 프레임워크가 그 경로를 이미 서빙하는지 확인하라.</strong> 컨트롤러 단위 테스트는 &quot;컨트롤러가 호출된다&quot;를 검증하지 않는다.</p>
<p><strong>9) 하지 않는 일을 광고하지 마라.</strong> 기본값을 확인 없이 내보내는 것도 광고다. 그리고 컬렉션 빌더에서 <code>clear()</code>를 빼먹으면 덮은 게 아니라 더한 것이다.</p>
<p><strong>10) 여러 곳에서 비교되는 식별자는 만드는 함수가 하나여야 한다.</strong> 슬래시 하나에 &quot;로그인은 됐는데 401&quot;이 된다.</p>
<p><strong>11) 프록시를 지나는 계약은 배포해서 curl로 확인하고, 그 결과를 표로 남겨라.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프롬프트를 고치지 않고 고쳤다 — LLM과 규칙 파서의 경계를 어디에 그을까]]></title>
            <link>https://velog.io/@gitit-sehyeon/%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%EB%A5%BC-%EA%B3%A0%EC%B9%98%EC%A7%80-%EC%95%8A%EA%B3%A0-%EA%B3%A0%EC%B3%A4%EB%8B%A4-LLM%EA%B3%BC-%EA%B7%9C%EC%B9%99-%ED%8C%8C%EC%84%9C%EC%9D%98-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EC%96%B4%EB%94%94%EC%97%90-%EA%B7%B8%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@gitit-sehyeon/%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%EB%A5%BC-%EA%B3%A0%EC%B9%98%EC%A7%80-%EC%95%8A%EA%B3%A0-%EA%B3%A0%EC%B3%A4%EB%8B%A4-LLM%EA%B3%BC-%EA%B7%9C%EC%B9%99-%ED%8C%8C%EC%84%9C%EC%9D%98-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EC%96%B4%EB%94%94%EC%97%90-%EA%B7%B8%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Mon, 24 Aug 2026 01:25:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/4987c7ed-c50b-4194-b2ff-d38d294d33a1/image.png" alt=""></p>
<blockquote>
<p>사내 코드라 사람 이름·조직명은 바꿨다. 구조와 실측값은 그대로다.</p>
</blockquote>
<hr>
<h2 id="0-증상-두-개">0. 증상 두 개</h2>
<p>팀원 부재(연차·출장·교육)를 메신저 채널에서 자동으로 읽어 팀 보드에 채우는 기능이 있다. LLM이 메시지 원문을 읽고 후보를 뽑고, 우리 코드가 날짜를 정규화해 확정한다. 못 읽은 것은 <strong>미처리함</strong>으로 보내 사람이 직접 입력하게 한다.</p>
<p>알파에서 두 가지 지적을 받았다.</p>
<ol>
<li><code>홍길동 9/23 연차</code> 가 <strong>계속</strong> 미처리함으로 간다.</li>
<li>미처리함 항목의 <strong>사유가 전부 똑같다.</strong></li>
</ol>
<p>따로 보면 별개의 버그다. 같이 보면 한 뿌리다 — <strong>우리가 실패 근거를 아예 안 남긴다.</strong> 그래서 1번의 원인을 추측으로 찾고 있었다.</p>
<hr>
<h2 id="1-오진을-먼저-배제했다">1. 오진을 먼저 배제했다</h2>
<p><code>홍길동 9/23 연차</code>. 이름 + 날짜 + 종류. 이보다 쉬운 입력이 없다. 이걸 못 읽는다면 프롬프트 문제일 것 같다. 확인해 봤다.</p>
<p><strong>첫째, 이 모양은 이미 시스템 프롬프트에 예시로 들어 있었다.</strong> 사유(<code>note</code>) 규칙을 설명하는 자리에서, &quot;사유가 안 적힌 문장&quot;의 대표 예로 정확히 이 형태를 쓰고 있었다.</p>
<pre><code class="language-java">// LlmAbsenceExtractionGateway 의 시스템 프롬프트 중
&quot;&quot;&quot;
…
note 에는 &quot;왜 자리를 비우는지&quot;(사유·맥락)만 한국어로 짧게 쓴다. …
원문에 사유가 안 적혀 있으면(예: &quot;홍길동 8/20 연차&quot;) note 는 빈 문자열이다. 지어내지 마라.
…
&quot;&quot;&quot;</code></pre>
<p>즉 모델이 읽는 지시문 안에 이 형태가 <strong>부재 문장의 전형</strong>으로 이미 박혀 있었다. 그런데도 못 읽었다.</p>
<p><strong>둘째, 후보 0건일 때의 1회 재시도도 이미 있었다.</strong></p>
<pre><code class="language-java">if (candidates.isEmpty()
    &amp;&amp; AbsenceMessageNormalizer.suspectsMissedAbsence(message.text(), rosterNames)) {
    // 의심스러운데 0건이면 한 번 더 부른다.
    …
}</code></pre>
<p><strong>셋째, 알파 로그에 반복 실패가 남아 있었다.</strong></p>
<pre><code>17:21:32  seq=14  의심스러운 메시지 → 한 번 더 시도
17:32:47  seq=16  의심스러운 메시지 → 한 번 더 시도</code></pre><p>둘 다 재시도까지 하고 빈손이었다. 그러니까 문제는 이거다.</p>
<ul>
<li>프롬프트에 예시로 들어 있다 → 프롬프트 부족이 아니다</li>
<li>재시도가 있다 → 일시적 오류가 아니다</li>
<li>그런데 반복해서 실패한다</li>
</ul>
<p>남는 결론은 하나였다. <strong>모델이 비결정적이라는 성질</strong> 자체다. 프롬프트를 더 붙이는 건 처방이 아니다. 같은 성질의 도구에 같은 것을 다시 기대하는 일이다.</p>
<p>그래서 방향을 바꿨다. 프롬프트를 고치지 않는다. <strong>정형 입력만 결정적으로 처리한다.</strong></p>
<hr>
<h2 id="2-규칙-파서를-llm-앞에-둔다">2. 규칙 파서를 LLM 앞에 둔다</h2>
<p>라우팅은 6줄이다.</p>
<pre><code class="language-java">/**
 * 후보를 뽑는다. &lt;b&gt;정형 문장은 규칙이 먼저 처리하고 LLM 을 아예 부르지 않는다.&lt;/b&gt;
 *
 * &lt;p&gt;&quot;홍길동 9/23 연차&quot; 같은 한 줄은 이미 프롬프트의 예시인데도 모델이 반복해서 놓쳤다.
 * 비결정적 도구에 정형 입력을 맡긴 것이 원인이라, 그 부분만 결정적으로 바꾼다.
 * 애매한 문장은 그대로 LLM 이 읽는다.
 */
private AbsenceExtractionOutcome extractCandidates(
        AbsenceChannelMessage message, Set&lt;String&gt; rosterNames) {

    Optional&lt;AbsenceCandidate&gt; ruled =
        AbsenceMessageRuleExtractor.extract(message.text(), rosterNames);
    if (ruled.isPresent()) {
        log.info(&quot;정형 문장을 규칙으로 읽었다(seq={}) — LLM 을 부르지 않는다.&quot;, message.seq());
        return new AbsenceExtractionOutcome.Extracted(List.of(ruled.get()));
    }
    try {
        return new AbsenceExtractionOutcome.Extracted(
            extractionGateway.extract(message.text()));
    } catch (RuntimeException e) {
        log.warn(&quot;후보 추출 실패(seq={}, 예외={})&quot;, message.seq(), e.getClass().getSimpleName(), e);
        return new AbsenceExtractionOutcome.Failed(describeFailure(e));
    }
}</code></pre>
<p>부수 효과로 이 문장들은 <strong>LLM 호출·지연·비용이 0</strong>이 된다. 하지만 그게 목적이 아니다. 목적은 <strong>결정성</strong>이다. <code>홍길동 9/23 연차</code>는 이제 &quot;이번엔 되네&quot;가 아니라 &quot;이 문장은 된다&quot;가 된다.</p>
<p>이 파서는 <strong>도메인 레이어</strong>에 둔다(<code>domain/model/AbsenceMessageRuleExtractor</code>). 외부 호출이 없고 팀 명단만 받아 판단하는 순수 함수라, 인프라도 유스케이스도 아니다.</p>
<hr>
<h2 id="3-다섯-조건--그리고-다섯째가-관문이다">3. 다섯 조건 — 그리고 다섯째가 관문이다</h2>
<p>규칙이 손대는 문장의 조건은 <strong>전부 만족</strong>해야 한다.</p>
<pre><code class="language-java">/**
 * 정형 문장이면 후보 하나를, 아니면 {@link Optional#empty()} 를 돌려준다
 * (그 경우 LLM 이 읽는다).
 *
 * &lt;ol&gt;
 *   &lt;li&gt;한 줄이고 40자 이하&lt;/li&gt;
 *   &lt;li&gt;팀 명단 이름이 &lt;b&gt;정확히 하나&lt;/b&gt; 등장&lt;/li&gt;
 *   &lt;li&gt;종류 어휘가 &lt;b&gt;정확히 하나&lt;/b&gt; 등장&lt;/li&gt;
 *   &lt;li&gt;시각·반차·취소 표현이 없다&lt;/li&gt;
 *   &lt;li&gt;이름·종류를 지운 나머지가 &lt;b&gt;날짜 글자뿐&lt;/b&gt;이다&lt;/li&gt;
 * &lt;/ol&gt;
 */
public static Optional&lt;AbsenceCandidate&gt; extract(String messageText, Set&lt;String&gt; rosterNames) {</code></pre>
<p>1~4번은 흔한 가드다. 5번이 이 설계의 핵심이다.</p>
<pre><code class="language-java">/**
 * 이름·종류를 지운 뒤 남아도 되는 글자. &lt;b&gt;날짜 표현에 쓰이는 문자만&lt;/b&gt; 허용한다 —
 * 이 검사가 &quot;정형 문장&quot;의 관문이다. 조사·설명·이모지가 하나라도 남으면 규칙이 손대지 않는다.
 *
 * &lt;p&gt;여기에 한글을 넣지 않는다(&quot;내일&quot;·&quot;다음 주 월요일&quot; 같은 상대 날짜는 규칙 대상이
 * 아니다). 정규화는 그것도 해석할 수 있지만, 상대 날짜가 섞인 문장은 대개 설명이 붙어
 * 있어 LLM 쪽이 낫다.
 */
private static final Pattern DATE_ONLY_LEFTOVER = Pattern.compile(&quot;[0-9\\s/.\\-~월일()]*&quot;);</code></pre>
<pre><code class="language-java">String leftover = text.replace(name, &quot; &quot;).replace(kindWord, &quot; &quot;);
if (!DATE_ONLY_LEFTOVER.matcher(leftover).matches()) {
    return Optional.empty();     // LLM 이 읽는다
}
if (leftover.chars().noneMatch(Character::isDigit)) {
    // 날짜가 없으면 규칙이 만들 수 있는 게 없다(&quot;홍길동 연차&quot; — 언제인지 모른다).
    return Optional.empty();
}</code></pre>
<p><strong>뽑는 대신 지우고 남는 걸 본다.</strong> 이 뒤집기가 중요하다.</p>
<p>정규식으로 &quot;이름 + 날짜 + 종류&quot; 패턴을 매칭하려 들면 매칭이 성공한 문장에 <strong>다른 뜻이 더 붙어 있는지</strong>를 알 수 없다. <code>홍길동 9/23 연차인데 오후에 잠깐 들를게요</code>는 패턴이 매칭된다. 그리고 완전히 다른 뜻이다.</p>
<p>지우고 남는 걸 보면 이 문제가 원천적으로 없다. <strong>&quot;이 문장에는 이름·종류·날짜 말고는 아무것도 없다&quot;</strong> 를 증명하는 방식이라, 예상 못 한 표현이 붙어 있으면 자동으로 빠진다. 화이트리스트가 블랙리스트보다 강한 것과 같은 이유다.</p>
<p>실제 동작:</p>
<table>
<thead>
<tr>
<th>문장</th>
<th>규칙</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>홍길동 9/23 연차</code></td>
<td>잡는다</td>
<td>leftover = <code>9/23</code></td>
</tr>
<tr>
<td><code>홍길동 8월 22일 교육</code></td>
<td>잡는다</td>
<td>leftover = <code>8월 22일</code></td>
</tr>
<tr>
<td><code>홍길동 9/1~9/3 출장</code></td>
<td>잡는다</td>
<td>leftover = <code>9/1~9/3</code></td>
</tr>
<tr>
<td><code>홍길동 9/23 연차입니다</code></td>
<td>LLM으로</td>
<td><code>입니다</code> 가 남는다</td>
</tr>
<tr>
<td><code>홍길동 9/23 오전 반차</code></td>
<td>LLM으로</td>
<td>4번(시각 표현)</td>
</tr>
<tr>
<td><code>홍길동 9/23 연차 취소</code></td>
<td>LLM으로</td>
<td>4번(취소 표현)</td>
</tr>
<tr>
<td><code>홍길동 김철수 9/23 연차</code></td>
<td>LLM으로</td>
<td>2번(이름 둘)</td>
</tr>
<tr>
<td><code>홍길동 연차</code></td>
<td>LLM으로</td>
<td>날짜 없음</td>
</tr>
<tr>
<td><code>홍길동 9/23 연차 교육</code></td>
<td>LLM으로</td>
<td>3번(종류 둘)</td>
</tr>
</tbody></table>
<p>조사 한 글자(<code>입니다</code>)에 규칙이 손을 떼는 게 과해 보일 수 있다. 의도다. 다음 절이 그 이유다.</p>
<hr>
<h2 id="4-왜-이렇게-좁게-잡나--오탐과-미탐의-비용이-다르다">4. 왜 이렇게 좁게 잡나 — 오탐과 미탐의 비용이 다르다</h2>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;좁게 잡는다.&lt;/b&gt; 애매한 문장은 규칙이 손대지 않고 LLM 으로 넘긴다. 잘못 만든
 * 부재는 사람이 지울 수 있지만, 규칙이 잡담을 부재로 만들면 조직장이 매번 그걸 지워야
 * 한다 — 그러면 이 장치가 오히려 일을 늘린다.
 */</code></pre>
<p>이게 설계 판단의 전부다. 자동화 장치의 정밀도(precision)와 재현율(recall)은 <strong>비용이 대칭이 아니다.</strong></p>
<ul>
<li><strong>미탐</strong>(규칙이 안 잡음) → LLM으로 넘어간다. 최악의 경우 미처리함에 남고 사람이 직접 입력한다. <strong>원래 상태와 같다.</strong></li>
<li><strong>오탐</strong>(규칙이 잡담을 부재로 만듦) → 팀 보드에 없는 부재가 생긴다. 사람이 발견하고 지워야 한다. <strong>원래 상태보다 나쁘다.</strong></li>
</ul>
<p>미탐의 비용이 오탐의 비용보다 훨씬 싸다면, 임계점은 한참 보수적인 쪽에 있어야 한다. 자동화가 사람 일을 줄이려고 만든 것인데 <strong>매번 청소를 요구하면 그 장치는 순손실이다.</strong></p>
<p>같은 이유로 두 종류를 규칙에서 뺐다.</p>
<pre><code class="language-java">/** 시각·반차가 섞인 문장. 시각 해석까지 규칙이 떠안으면 좁게 유지할 수 없다. */
private static final Pattern TIME_HINT =
    Pattern.compile(&quot;반차|반휴|오전|오후|시\\b|:\\d{2}|시부터|시까지&quot;);

/** 무르는 문장. 규칙이 취소를 다루지 않는다 — 잘못 지운 부재는 아무도 모른다. */
private static final Pattern CANCEL_HINT =
    Pattern.compile(&quot;취소|철회|안\\s*가|출근합니다|복귀&quot;);</code></pre>
<p>취소를 뺀 이유가 특히 그렇다. <strong>잘못 만든 부재는 눈에 띈다. 잘못 지운 부재는 아무도 모른다.</strong> 파괴적 연산은 자동화의 마지막에 넣어야 하는 게 아니라, 아예 다른 관문을 세워야 한다.</p>
<p>같은 원칙이 LLM 프롬프트에도 이미 있었다는 게 재미있다.</p>
<pre><code>intent 는 … 취소인지 애매하면 CREATE 를 골라라 — 잘못 만든 부재는 사람이 지우면 되지만
잘못 지운 부재는 아무도 모른다.</code></pre><p>프롬프트로 &quot;애매하면 안전한 쪽&quot;을 부탁하는 것과, 규칙에서 그 분기를 <strong>아예 없애는</strong> 것의 차이가 이 글 전체의 요지다. 부탁은 대체로 지켜진다. 대체로 지켜지는 것과 항상 지켜지는 것의 차이가 사고를 만든다.</p>
<hr>
<h2 id="5-날짜는-규칙이-파싱하지-않는다">5. 날짜는 규칙이 파싱하지 않는다</h2>
<p>이 부분을 특히 조심했다. 규칙 파서가 날짜를 안 읽는다.</p>
<pre><code class="language-java">// dateText 에 원문을 그대로 넘긴다 — 날짜 해석은 정규화의 몫이다.
// timeText 는 비운다(시각 표현이 있는 문장은 위에서 이미 걸렀다).
return Optional.of(new AbsenceCandidate(
    name, kindHint, text, &quot;&quot;, &quot;&quot;, AbsenceCandidateConfidence.HIGH, AbsenceIntent.CREATE));</code></pre>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;날짜를 여기서 파싱하지 않는다.&lt;/b&gt; 이 클래스가 하는 판정은 &quot;이 문장은 이름+종류+
 * 날짜뿐이다&quot; 하나이고, 날짜 해석은 {@code AbsenceMessageNormalizer.normalize} 가 그대로
 * 한다(상대 날짜·범위·연도 추론이 전부 거기 있다). 그래서 {@code dateText} 에는 원문을
 * 그대로 넘긴다 — &lt;b&gt;날짜 문법을 두 곳에 두면 한쪽만 고쳐지는 날이 온다.&lt;/b&gt;
 */</code></pre>
<p>날짜 파싱은 규칙 파서가 하기에 딱 좋아 보이는 일이다. 그래서 함정이다. 이미 정규화 레이어에 연도 추론·범위 해석·상대 날짜가 다 있다. 규칙 파서에 또 만들면 <strong>문법이 두 벌</strong>이 된다. 그러면 언젠가 &quot;메신저에서 온 건 되는데 재시도로 누르면 다르게 나온다&quot;가 생긴다.</p>
<p>그래서 규칙 파서의 책임을 한 문장으로 못 박았다.</p>
<blockquote>
<p>규칙이 하는 판정은 <strong>&quot;이 문장은 이름+종류+날짜뿐이다&quot;</strong> 하나다.</p>
</blockquote>
<p>LLM 앞에 결정적 필터를 두는 설계에서 가장 흔한 실패가 이거라고 본다. <strong>필터가 파서로 자라난다.</strong> 필터는 라우터로 남아야 하고, 라우팅 판정과 해석은 다른 일이다.</p>
<p>종류 어휘 매핑에서도 같은 원칙을 지켰다.</p>
<pre><code class="language-java">/**
 * 종류 어휘 → {@code kindHint}. 정규화의 부재 어휘 중 &lt;b&gt;종류를 확정할 수 있는 말만&lt;/b&gt;
 * 여기 있다. 나머지(병가·재택 등)는 {@code OTHER} 로 두는데, 그건 저장 시점의 결론과 같다.
 *
 * &lt;p&gt;순서가 뜻이 있다 — 긴 말을 먼저 본다(&quot;연차휴가&quot; 가 &quot;휴가&quot; 로 먼저 걸리지 않게).
 */
private static final Map&lt;String, String&gt; KIND_WORDS = new LinkedHashMap&lt;&gt;();
static {
    KIND_WORDS.put(&quot;연차&quot;, &quot;ANNUAL_LEAVE&quot;);
    KIND_WORDS.put(&quot;연가&quot;, &quot;ANNUAL_LEAVE&quot;);
    KIND_WORDS.put(&quot;월차&quot;, &quot;ANNUAL_LEAVE&quot;);
    KIND_WORDS.put(&quot;휴가&quot;, &quot;ANNUAL_LEAVE&quot;);
    KIND_WORDS.put(&quot;교육&quot;, &quot;TRAINING&quot;);
    KIND_WORDS.put(&quot;출장&quot;, &quot;BUSINESS_TRIP&quot;);
    …
}</code></pre>
<p><code>LinkedHashMap</code>을 쓴 이유가 주석에 있다. 부분 문자열 매칭 어휘 목록은 <strong>최장 우선</strong>이어야 하고, 순서가 의미를 갖는 자료구조를 골라야 그게 코드로 보인다. <code>Map.of()</code>를 쓰면 조용히 깨지고 재현이 안 된다.</p>
<hr>
<h2 id="6-두-번째-증상--사유가-전부-똑같던-이유">6. 두 번째 증상 — 사유가 전부 똑같던 이유</h2>
<p>원인은 고정 문장 하나였다.</p>
<pre><code class="language-java">// 예전
return new SkippedMessage(seq, excerpt, true, false,
    &quot;부재로 보이는데(팀 명단 이름 + 부재 관련 표현) 자동으로 후보를 만들지 못했습니다&quot;
    + &quot; — 직접 확인해 입력하세요.&quot;);</code></pre>
<p>사유 칸이 &quot;무엇이 왜 실패했는지&quot;가 아니라 <strong>분류 이름만 반복</strong>하고 있었다. 미처리함에 20건이 쌓이면 사유 20개가 전부 같은 문장이다. 그러면 사유 칸이 없는 것과 같다.</p>
<p>고친 뒤:</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;의심스러운 경우의 사유에 근거를 적는다.&lt;/b&gt; 예전에는 고정 문장 하나였고, 그래서
 * 미처리함에 쌓인 항목의 사유가 &lt;b&gt;전부 똑같아&lt;/b&gt; 무엇을 어떻게 처리해야 하는지 알 수
 * 없었다. 어떤 이름·어떤 표현 때문에 의심했는지가 곧 사람이 확인할 대상이므로 그 둘을
 * 문장에 담는다.
 *
 * @param names    원문에서 찾은 팀 명단 이름(의심 근거)
 * @param keywords 원문에서 찾은 부재 표현(의심 근거)
 */
public static SkippedMessage notAbsence(long seq, String excerpt, boolean suspected,
                                        Collection&lt;String&gt; names, Collection&lt;String&gt; keywords) {
    if (!suspected) {
        return new SkippedMessage(seq, excerpt, false, false, &quot;부재와 무관한 메시지로 보입니다.&quot;);
    }
    return new SkippedMessage(seq, excerpt, true, false,
        &quot;부재로 보이는데 자동으로 후보를 만들지 못했습니다(이름 %s + 표현 %s 을 찾았지만 후보 0건, 재시도 후에도 0건) — 직접 확인해 입력하세요.&quot;
            .formatted(quote(names), quote(keywords)));
}

/** 근거를 사유 문장에 넣을 형태로. 비어 있으면 그 사실도 그대로 적는다(빈 괄호를 만들지 않는다). */
private static String quote(Collection&lt;String&gt; values) {
    if (values == null || values.isEmpty()) {
        return &quot;(없음)&quot;;
    }
    return values.stream().map(&quot;&#39;%s&#39;&quot;::formatted).collect(Collectors.joining(&quot;, &quot;));
}</code></pre>
<p>결과:</p>
<blockquote>
<p>부재로 보이는데 자동으로 후보를 만들지 못했습니다(이름 &#39;홍길동&#39; + 표현 &#39;연차&#39; 을 찾았지만 후보 0건, 재시도 후에도 0건) — 직접 확인해 입력하세요.</p>
</blockquote>
<p><strong>분류를 말하지 말고 판정 근거를 말하라.</strong> 이게 이 절의 요지다. <code>suspectsMissedAbsence</code>가 true를 준 근거는 이미 코드 안에 있었다. 밖으로 안 내보냈을 뿐이다. 그래서 판정 함수를 근거를 돌려주는 두 접근자로 다시 썼다.</p>
<pre><code class="language-java">Set&lt;String&gt; names = AbsenceMessageNormalizer.matchedRosterNames(message.text(), rosterNames);
Set&lt;String&gt; keywords = AbsenceMessageNormalizer.matchedAbsenceKeywords(message.text());
boolean suspected = !names.isEmpty() &amp;&amp; !keywords.isEmpty();</code></pre>
<p><code>boolean</code>을 돌려주던 함수를 <strong>근거를 돌려주는 함수 둘 + 그 둘로 계산되는 boolean</strong>으로 바꾼 것이다. 판정 함수가 boolean만 주면, 그 판정을 사용자에게 설명해야 할 때 근거를 다시 계산하거나 없이 말해야 한다.</p>
<p>로그에는 같은 판정을 남기되 <strong>개수만</strong> 남긴다.</p>
<pre><code class="language-java">// 사유에 근거가 들어가므로 로그에도 같은 판정을 남긴다 — 원문·이름 없이 seq 와 개수만
// (로그에 부재 원문을 넣지 않는다는 규칙은 그대로).
log.info(&quot;의심스러운 메시지를 미처리함으로 보낸다(seq={}, 이름 {}개, 표현 {}개).&quot;,
    message.seq(), names.size(), keywords.size());</code></pre>
<p>화면에 보이는 것과 로그에 남는 것의 기준이 다르다. 화면은 그 사람이 이미 볼 수 있는 데이터를 다시 보여 주는 자리고, 로그는 보존되고 수집되는 자리다.</p>
<hr>
<h2 id="7-이미-쌓인-항목--다시-시도">7. 이미 쌓인 항목 — &quot;다시 시도&quot;</h2>
<p>규칙 파서를 넣어도 <strong>이미 미처리함에 쌓인 항목은 그대로 남는다.</strong> 수집은 워터마크가 전진해 지나간 메시지를 다시 읽지 않기 때문이다. 그래서 저장된 원문을 새 파서로 다시 읽는 버튼을 만들었다.</p>
<p><code>POST /console/v1/team-board/absence-imports/pending/{id}/retry</code></p>
<h3 id="7-1-재시도는-llm을-다시-부르지-않는다">7-1. 재시도는 LLM을 다시 부르지 않는다</h3>
<p>이 결정이 반직관적이라 이유를 세 개 적어 뒀다.</p>
<ol>
<li>그 항목들은 이미 LLM이 <strong>두 번</strong>(최초 + 수집 시 1회 재시도) 실패한 것이다. 세 번째 호출은 처방이 아니다.</li>
<li>미처리함에는 <strong>메시지 발신일이 없다</strong>(수집 시각만 있다). 상대 날짜를 다시 해석하면 조용히 틀린 날짜가 만들어질 수 있는데, 규칙 파서는 절대 날짜만 받으므로 그 위험이 <strong>원천적으로</strong> 없다.</li>
<li>결정적이라 &quot;눌렀는데 이번엔 되네&quot;가 아니라 <strong>&quot;이 문장은 된다&quot;</strong> 가 된다.</li>
</ol>
<p>2번이 특히 마음에 든다. 규칙 파서를 좁게 만든 결정(§3에서 <code>DATE_ONLY_LEFTOVER</code>에 한글을 넣지 않은 것)이 여기서 <strong>다른 종류의 안전성</strong>으로 돌아왔다. 상대 날짜를 못 읽는 게 이 자리에서는 기능이다. 제약을 좁게 잡으면 나중에 예상 못 한 곳에서 이자가 붙는다.</p>
<h3 id="7-2-기준일이-없으면-날짜를-만들지-않는다">7-2. 기준일이 없으면 날짜를 만들지 않는다</h3>
<pre><code class="language-java">if (pending.detectedAt() == null) {
    // 기준일이 없으면 날짜를 해석할 수 없다. 서버 현재시각으로 대신하지 않는다 — 그건
    // 뒤늦게 누른 시점에 따라 결과가 달라진다는 뜻이고, 조용히 틀린 날짜를 만드는 길이다.
    return RetryResult.unresolved(&quot;수집 시각을 몰라 날짜를 다시 해석할 수 없습니다 — 직접 확정해 주세요.&quot;);
}
LocalDate baseDate = pending.detectedAt().atZone(KST).toLocalDate();</code></pre>
<p>기준일은 <strong>수집 시각</strong>이다. 서버 현재시각을 쓰지 않는다. 같은 버튼을 3일 뒤에 눌러도 결과가 같아야 한다.</p>
<p>실환경에서는 <code>detected_at</code>이 <code>NOT NULL</code>이라 이 분기에 닿지 않는다. 그래도 남겼다 — 컬럼이 바뀌어도 <strong>조용히 1970년 부재가 만들어지지 않게</strong> 하는 자리다. 테스트가 그 자리를 지킨다. <code>null</code>을 만나면 기본값으로 때우는 코드가 데이터 사고의 절반이라고 본다.</p>
<h3 id="7-3-실패도-200이다">7-3. 실패도 200이다</h3>
<pre><code class="language-java">if (ruled.isEmpty()) {
    return RetryResult.unresolved(
        &quot;정형 문장이 아니어서 규칙으로 읽을 수 없습니다&quot;
        + &quot;(설명이 붙었거나 반차·취소·이름이 둘 이상) — 직접 확정해 주세요.&quot;);
}</code></pre>
<p>&quot;여전히 못 읽었다&quot;는 <strong>오류가 아니라 결과</strong>다. 그 사유가 항목 옆에 떠야 사용자가 다음 행동(직접 확정)을 고를 수 있다. 4xx로 만들면 그 문장이 오류 배너로 밀려나고, 배너는 3초 뒤에 사라진다.</p>
<p>그리고 사유에 <strong>왜 못 읽었는지의 후보를 적었다.</strong> &quot;규칙으로 읽을 수 없습니다&quot;에서 끝나면 사용자는 뭘 고쳐야 할지 모른다.</p>
<h3 id="7-4-순서--만든-다음에-지운다">7-4. 순서 — 만든 다음에 지운다</h3>
<pre><code class="language-java">MemberAbsence saved = memberAbsenceRepository.save(
    new MemberAbsence(null, accepted.memberName(), accepted.plan()));
// 부재를 만든 다음에 지운다(순서가 뒤집히면 저장 실패 시 그 메시지가 어디에도 안 남는다).
pendingAbsenceImportRepository.delete(pendingId);</code></pre>
<p>지우고 만들면, 만들기가 실패했을 때 그 메시지가 <strong>어디에도 안 남는다.</strong> 미처리함에서도 사라지고 부재도 없다. 사용자는 자기가 신고한 게 사라진 걸 모른다.</p>
<p>트랜잭션 하나로 묶으면 되는 것 같지만, 순서를 이렇게 두면 트랜잭션이 없어도 최악이 &quot;중복&quot;이지 &quot;소실&quot;이 아니다. 그리고 중복 방어는 따로 있다.</p>
<pre><code class="language-java">// 원본 메시지 id 를 미처리함에 남기지 않으므로 origin 없이 저장한다. 중복 방어(같은
// 메시지가 두 번 부재가 되는 것)는 성공 시 미처리 항목을 지우는 것으로 대신한다 —
// 워터마크가 이미 전진해 그 메시지가 수집으로 다시 들어올 일도 없다.</code></pre>
<p><strong>최악의 결과를 소실이 아니라 중복으로 만들어라.</strong> 중복은 눈에 보이고 지울 수 있다.</p>
<hr>
<h2 id="8-검증">8. 검증</h2>
<ul>
<li>규칙 파서 테스트 10건, 재시도 테스트 5건 추가</li>
<li>실제로 놓쳤던 <code>홍길동 9/23 연차</code> → 후보가 잡히고 정규화까지 통과해 <strong>2026-09-23 연차로 확정</strong>(발신일 기준 연도 추론 포함)</li>
<li>재시도 테스트에 <strong>LLM 호출 수 0</strong> 을 명시적으로 검증하는 케이스를 넣었다. &quot;안 부른다&quot;가 설계의 핵심이면 그건 주석이 아니라 테스트여야 한다</li>
<li>규칙이 손대지 않는 문장 8종(설명 붙음 · 반차 · 취소 · 이름 둘 · 날짜 없음 · 종류 둘 · 여러 줄 · 40자 초과)을 전부 케이스로 고정</li>
</ul>
<p>마지막 항목이 중요하다. 이런 필터의 테스트는 <strong>잡는 것</strong>보다 <strong>안 잡는 것</strong>을 고정하는 쪽이 값을 한다. 누가 나중에 <code>DATE_ONLY_LEFTOVER</code>에 한글을 한 글자 추가하는 순간 8건이 깨진다.</p>
<hr>
<h2 id="9-정리--llm-앞에-결정적-필터를-둘-때">9. 정리 — LLM 앞에 결정적 필터를 둘 때</h2>
<p><strong>1) 재시도가 이미 있는데도 반복 실패하면 프롬프트 문제가 아니다.</strong>
프롬프트에 예시로 있는 입력이 실패하고 있는지 먼저 확인하라. 있으면 원인은 비결정성이고, 프롬프트를 더 붙이는 건 처방이 아니다.</p>
<p><strong>2) 필터는 라우터로 남겨라. 파서로 자라게 하지 마라.</strong>
&quot;이 입력은 정형이다&quot;를 판정하는 것과 그 입력을 해석하는 것은 다른 일이다. 해석은 이미 있는 곳에 맡기고 원문을 그대로 넘겨라. 문법이 두 벌이 되면 한쪽만 고쳐지는 날이 온다.</p>
<p><strong>3) 뽑지 말고 지우고 남는 걸 봐라.</strong>
패턴 매칭은 &quot;이것 말고 다른 게 더 있는지&quot;를 알려주지 않는다. 알려진 것을 지우고 남은 잔여물을 화이트리스트로 검사하면, 예상 못 한 표현이 자동으로 빠진다.</p>
<p><strong>4) 오탐과 미탐의 비용을 비교해서 임계점을 정하라.</strong>
미탐이 원래 상태로 돌아가는 것뿐이고 오탐이 사람에게 청소를 요구한다면, 임계점은 한참 보수적인 쪽이다. 청소를 요구하는 자동화는 순손실이다.</p>
<p><strong>5) 파괴적 연산은 아예 규칙에서 빼라.</strong>
잘못 만든 것은 보인다. 잘못 지운 것은 아무도 모른다.</p>
<p><strong>6) 분류를 말하지 말고 판정 근거를 말하라.</strong>
&quot;부재로 보이는데 못 읽었습니다&quot;는 분류 이름이다. &quot;이름 &#39;홍길동&#39; + 표현 &#39;연차&#39; 를 찾았지만 후보 0건&quot;은 근거다. 근거가 곧 사람이 확인할 대상이다.</p>
<p><strong>7) 판정 함수가 boolean만 돌려주면 그 판정을 설명할 수 없다.</strong>
근거를 돌려주는 함수를 만들고 boolean은 그걸로 계산하라.</p>
<p><strong>8) 실패가 결과인지 오류인지 구분하라.</strong>
&quot;여전히 못 읽었다&quot;는 결과다. 200으로 사유를 돌려주면 사용자가 다음 행동을 고를 수 있다. 4xx는 배너로 밀려나 3초 뒤에 사라진다.</p>
<p><strong>9) 순서로 최악을 소실이 아니라 중복으로 만들어라.</strong></p>
<p><strong>10) 기본값으로 <code>null</code>을 때우지 마라.</strong>
기준일이 없으면 날짜를 만들지 않는 게 맞다. 서버 현재시각으로 대신하면 조용히 틀린 데이터가 생긴다.</p>
<hr>
<h2 id="10-남은-생각">10. 남은 생각</h2>
<p>LLM을 쓰는 코드를 짜다 보면 &quot;여기도 LLM으로 하면 되겠네&quot;가 계속 생긴다. 실제로 되기도 한다. 그런데 <strong>정형 입력에 비결정적 도구를 쓰면 그 부분의 신뢰도가 전체의 신뢰도를 정한다.</strong> 가장 쉬운 입력이 가장 자주 실패하는 걸 사용자가 보면, 어려운 입력의 성공은 아무 의미가 없다.</p>
<p>이 변경으로 없어진 게 하나 더 있다. 지원이 필요한 질문 — &quot;왜 이건 안 읽혀요?&quot; 이제 정형 문장에 대해서는 답이 있다. <strong>&quot;그 문장은 규칙이 읽고, 규칙은 이 테스트가 지킨다.&quot;</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LLM이 쓴 문서에 린터를 붙였다 — 규격을 문서가 아니라 테스트로 옮긴 3주]]></title>
            <link>https://velog.io/@gitit-sehyeon/LLM%EC%9D%B4-%EC%93%B4-%EB%AC%B8%EC%84%9C%EC%97%90-%EB%A6%B0%ED%84%B0%EB%A5%BC-%EB%B6%99%EC%98%80%EB%8B%A4-%EA%B7%9C%EA%B2%A9%EC%9D%84-%EB%AC%B8%EC%84%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%EC%98%AE%EA%B8%B4-3%EC%A3%BC</link>
            <guid>https://velog.io/@gitit-sehyeon/LLM%EC%9D%B4-%EC%93%B4-%EB%AC%B8%EC%84%9C%EC%97%90-%EB%A6%B0%ED%84%B0%EB%A5%BC-%EB%B6%99%EC%98%80%EB%8B%A4-%EA%B7%9C%EA%B2%A9%EC%9D%84-%EB%AC%B8%EC%84%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%EC%98%AE%EA%B8%B4-3%EC%A3%BC</guid>
            <pubDate>Mon, 24 Aug 2026 01:22:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/c89d2cb6-5205-4362-8a79-e1648952df72/image.png" alt=""></p>
<blockquote>
<p>사내 코드라 호스트·사람 이름·조직명은 바꿨다. 구조와 실측값은 그대로다.</p>
</blockquote>
<hr>
<h2 id="0-이-글이-다루는-문제">0. 이 글이 다루는 문제</h2>
<p>주간 보고서 초안을 LLM이 쓴다. 우리 앱이 MCP 서버가 되어 원재료(두레이 업무 본문·댓글·마감일)를 도구로 내보내고, Claude Code가 그 재료로 초안을 써서 다시 우리 앱에 올린다. 덱(A4 가로 슬라이드)까지 그 초안에서 나온다.</p>
<p>여기서 3주에 걸쳐 세 번 얻어맞았다. 순서가 중요하다.</p>
<ol>
<li><strong>1주차</strong> — 초안에 규격이 없었다. 매주 골격·어휘·분량이 그때 모델 판단에 달렸다.</li>
<li><strong>2주차</strong> — 규격을 만들어 한 편을 뽑아 인사팀에 보냈다. <strong>여덟 줄짜리 피드백 중 절반이 우리가 규격으로 강제한 것을 정확히 겨냥했다.</strong></li>
<li><strong>3주차</strong> — 지적받은 걸 다 규칙으로 옮겼다. 그리고 <strong>검사기 지적 0건인 초안이 &quot;가독성이 떨어진다&quot;는 답을 받았다.</strong></li>
</ol>
<p>3주차가 이 글의 핵심이다. 규칙을 어겼느냐가 아니라 <strong>재는 대상이 틀렸느냐</strong>가 문제였다.</p>
<hr>
<h2 id="1-1주차--프롬프트에-잘-적어-두기의-한계">1. 1주차 — &quot;프롬프트에 잘 적어 두기&quot;의 한계</h2>
<p>처음 상태는 흔한 모양이었다. 절차 프롬프트가 이렇게 말한다.</p>
<pre><code>3단계. 재료를 읽고 초안의 목차를 정한다.
4단계. 목차가 정해지면 publish_draft 로 올린다.</code></pre><p>이게 전부였다. 그래서 매주 나오는 초안이 <strong>독립 문서</strong>였다. 지난주 초안은 두레이 호출 0회로 공짜로 읽을 수 있는 재료인데 아무도 읽지 않았고, 골격도 어휘도 분량도 매주 달랐다. 주 간 비교가 불가능했다. &quot;마무리 단계&quot;라고 쓴 항목이 다음 주에 같은 뜻으로 쓰이지 않는다.</p>
<p>그래서 규격을 만들었다. 핵심 판단 두 개를 먼저 적어 둔다.</p>
<h3 id="판단-1--검사는-전부-규칙이다-llm을-부르지-않는다">판단 1 — 검사는 전부 규칙이다. LLM을 부르지 않는다</h3>
<p>LLM 출력 품질을 재는 데 다시 LLM을 쓰는 패턴(LLM-as-a-judge)을 쓰지 않았다. 이유는 단순하다.</p>
<ul>
<li>규격이 정한 것은 <strong>정형</strong>이다. 섹션이 있나, 몇 줄인가, 이 어휘를 썼나. 세는 일에 비결정적 도구를 쓸 이유가 없다.</li>
<li>규칙은 <strong>테스트로 고정된다.</strong> 심판 모델은 고정되지 않는다. &quot;지난주엔 통과했는데 이번 주엔 지적한다&quot;가 되는 순간 아무도 그 지적을 믿지 않는다.</li>
<li>규칙이 못 잡는 것(사실 왜곡, 인과 오류)은 <strong>여기서 잡는 척하지 않는다.</strong> 잡는 척하는 게 안 잡는 것보다 나쁘다.</li>
</ul>
<h3 id="판단-2--규격-위반이-저장을-막지-않는다">판단 2 — 규격 위반이 저장을 막지 않는다</h3>
<pre><code class="language-java">/**
 * 초안이 &lt;b&gt;규격을 지키지 않은 자리&lt;/b&gt; 하나.
 *
 * &lt;p&gt;&lt;b&gt;오류가 아니라 값이다.&lt;/b&gt; 규격 위반으로 저장을 거절하면 사람이 볼 초안이 아예
 * 안 올라간다. 그래서 저장은 그대로 되고, 이 목록이 응답에 함께 실려 Claude 가 고칠
 * 거리를 알게 된다.
 *
 * @param item 무엇이 규격과 다른지. &lt;b&gt;Claude 와 사용자가 같이 읽는다&lt;/b&gt;
 * @param line 본문의 행 번호(1부터). 문서 전체에 걸친 문제는 {@code null} —
 *             없는 행을 가리키면 받는 쪽이 그 줄을 찾다가 만다
 * @param fix  무엇을 하면 되는지. 이게 없으면 &quot;규격 위반&quot; 이라는 사실만 알고 손을 못 댄다
 */
public record DraftProblem(String item, Integer line, String fix) { }</code></pre>
<p><code>fix</code>가 별도 필드인 게 의도적이다. 린터 메시지가 &quot;규격 위반&quot;에서 끝나면 받는 쪽은 사실만 알고 손을 못 댄다. 그리고 이 메시지는 <strong>모델과 사람이 같이 읽는다</strong> — 도구 응답에도 실리고 화면 경고에도 그대로 나간다. 그래서 사람 말로 적는다.</p>
<p>또 하나. <strong>별도 <code>check_draft</code> 도구를 만들지 않았다.</strong> 부르지 않으면 끝이니까. 저장 응답(<code>publish_draft</code>·<code>update_draft</code>)에 실으면 건너뛸 수 없고 도구 개수도 늘지 않는다.</p>
<pre><code class="language-java">private static String message(String saved, List&lt;DraftProblem&gt; problems) {
    if (problems.isEmpty()) {
        return saved;
    }
    return &quot;%s 다만 규격과 어긋난 자리가 %d건 있습니다 — problems 를 먼저 고쳐 update_draft 로 &quot;
        .formatted(saved, problems.size()) + &quot;…&quot;;
}</code></pre>
<hr>
<h2 id="2-2주차--실물-피드백이-우리-규격을-정면으로-반박했다">2. 2주차 — 실물 피드백이 우리 규격을 정면으로 반박했다</h2>
<p>한 편을 뽑아 보냈고, 여덟 줄이 돌아왔다. 절반이 <strong>우리가 규격으로 강제한 것</strong>을 겨냥했다.</p>
<table>
<thead>
<tr>
<th>받은 말</th>
<th>우리 규격이 강제하던 것</th>
</tr>
</thead>
<tbody><tr>
<td>&quot;굳이 이번주/다음주로 나누기보다는 차라리 내용에 대한 요약이면 더 좋을 것 같음&quot;</td>
<td>프로젝트마다 <strong>이번 주 / 다음 주</strong> 두 묶음을 <strong>필수</strong>로</td>
</tr>
<tr>
<td>&quot;줄 글이 너무 길어 가독성이 떨어짐. 무슨 이야기를 하고자하는지 모르겠음&quot;</td>
<td>「한 눈에」는 <strong>&quot;불릿이 아니라 서술문&quot;</strong> 으로</td>
</tr>
<tr>
<td>&quot;맨 상위 진행/완료/대기 등 상태페이지는 필요없어 보임&quot;</td>
<td>「진행」 머리에 <strong>상태 집계 표</strong></td>
</tr>
<tr>
<td>&quot;말투가 너무 AI스러워 거부감이 있음&quot;, &quot;확정됐다 ▶ 확정됨&quot;</td>
<td>아무 제약 없음</td>
</tr>
<tr>
<td>&quot;어떤 내용이 바뀌었는지만 나오면 깔끔(상태 변경값에 대한 요약은x)&quot;</td>
<td><code>대기 → 완료</code> 를 예시로 제시</td>
</tr>
<tr>
<td>&quot;업무를 찾을 수 없다 → 요 내용 빼고는 좋아보입니다&quot;</td>
<td>못 읽은 업무를 「확인 필요」에 적게</td>
</tr>
</tbody></table>
<h3 id="여기서-배운-것-기간이-아니라-주제">여기서 배운 것: 기간이 아니라 주제</h3>
<p>이게 가장 큰 구조 변경이었다.</p>
<pre><code>기간 축                          주제 축
## 진행                          ## 진행
### 프로젝트 A                   ### 심사표 개편
  이번 주: …                       (이번 주 움직임 + 다음 주 계획 + 배경을
  다음 주: …                        한 덩어리로)
### 프로젝트 B                   ### 현장 운영
  이번 주: …
  다음 주: …</code></pre><p>기간으로 자르면 <strong>한 주제가 두 칸으로 찢어지고 읽는 사람이 머리로 다시 붙여야 한다.</strong> 시점이 필요한 문장에는 그 문장 안에 적으면 된다(&quot;다음 주 초 확정 예정&quot;).</p>
<p>부수적으로 하나 더 못 박았다. <strong>주제는 프로젝트가 아니다.</strong> 한 프로젝트가 여러 주제로 갈리고(&quot;운영·현장&quot; / &quot;심사표&quot;), 한 주제가 여러 프로젝트에 걸친다. 그리고 주제 단위로 쓰면 <strong>그 주 기간 밖의 것도 끌어올 수 있다</strong> — 그 주에 움직였는지는 <em>고르는 기준</em>일 뿐이고 <em>쓰는 단위</em>가 아니다.</p>
<h3 id="그리고-진짜-교훈-문서에만-적으면-다음-주에-같은-지적을-다시-받는다">그리고 진짜 교훈: 문서에만 적으면 다음 주에 같은 지적을 다시 받는다</h3>
<p>여덟 줄을 규격 문서에 반영하는 건 30분이면 된다. 그렇게 하면 다음 주에 같은 지적을 받는다. <strong>문장으로만 적은 규칙은 지켜지지 않는다.</strong> 그래서 전부 검사기로 옮겼다.</p>
<p>말투도 옮겼다. &quot;AI스러워 거부감이 있음&quot;은 취향 문제로 보이지만 실제로는 <strong>받는 쪽이 읽기를 멈추는 자리</strong>였다.</p>
<pre><code class="language-java">/**
 * &lt;b&gt;쓰지 않는 관념어.&lt;/b&gt;
 *
 * &lt;p&gt;공통점은 &lt;b&gt;무엇이 일어났는지를 지운다&lt;/b&gt;는 것이다 — &quot;불일치 해소&quot; 는 무엇이 어떻게
 * 달랐고 어느 쪽으로 맞췄는지를 말하지 않는다. 목록을 짧게 유지한다: 정당하게 쓰일 수
 * 있는 말까지 넣으면 지적이 노이즈가 되고, 그러면 규격 전체를 믿지 않게 된다.
 */
public static final List&lt;String&gt; BANNED_WORDS = List.of(
    &quot;불일치 해소&quot;, &quot;정합성&quot;, &quot;고도화&quot;, &quot;선제적&quot;, &quot;유의미&quot;, &quot;원활히&quot;, &quot;만전&quot;, &quot;제고&quot;,
    &quot;이슈 대응&quot;, &quot;적극 검토&quot;, &quot;지속 모니터링&quot;);

/** 개조식 종결. &quot;확정됐다 ▶ 확정됨&quot;. */
public static final List&lt;String&gt; SENTENCE_ENDINGS =
    List.of(&quot;됨&quot;, &quot;함&quot;, &quot;임&quot;, &quot;예정&quot;, &quot;필요&quot;, &quot;없음&quot;);</code></pre>
<p><strong>목록을 짧게 유지한 판단이 중요하다.</strong> 금지어 목록은 늘리기 쉽고, 늘리면 정당하게 쓰인 말까지 걸린다. 오탐이 몇 번 나오면 사람은 그 규칙만 무시하지 않는다 — <strong>규격 전체를 믿지 않게 된다.</strong> 린터의 신뢰는 개별 규칙이 아니라 전체 단위로 무너진다.</p>
<h3 id="구현에서-밟은-것-검사-순서가-결과를-바꾼다">구현에서 밟은 것: 검사 순서가 결과를 바꾼다</h3>
<p>말투 검사에서 곧바로 걸렸다.</p>
<pre><code class="language-java">/** 서술체 종결. `~했다` · `~한다` · `~된다` · `~있다` 를 잡는다. */
private static final Pattern NARRATIVE_ENDING = Pattern.compile(&quot;다\\.?$&quot;);</code></pre>
<p>근거 링크가 불릿 끝에 <code>([업무번호](주소))</code> 로 붙어 있다. <strong>링크를 먼저 떼지 않으면 문장이 <code>)</code> 로 끝나서 링크 달린 줄이 전부 통과한다.</strong> 그리고 규격은 모든 사실 불릿에 근거 링크를 요구하니, 결과적으로 <strong>검사가 사실상 아무것도 안 잡는 상태</strong>가 된다.</p>
<pre><code class="language-java">String text = plain(line.text());   // 링크·마크업을 먼저 뗀다
if (BULLET.matcher(line.text()).find() &amp;&amp; NARRATIVE_ENDING.matcher(text).find()) {
    narrative.add(line);
}</code></pre>
<p>같은 이유로 「근거」 섹션은 말투 검사를 <strong>건너뛴다.</strong> 두레이 업무 제목을 그대로 옮기는 자리라 우리가 말투를 정할 수 없다. 우리가 못 고치는 것을 지적하면 그것도 노이즈다.</p>
<hr>
<h2 id="3-3주차--검사기-지적-0건인데-가독성이-떨어진다">3. 3주차 — 검사기 지적 0건인데 &quot;가독성이 떨어진다&quot;</h2>
<p>이게 이 글을 쓰는 이유다. 주제 축으로 고친 뒤 한 편을 더 뽑았다. 검사기 지적 <strong>0건</strong>. 그리고 &quot;읽기 어렵다&quot;는 답을 받았다.</p>
<p>규칙을 어긴 게 아니었다. <strong>재는 대상이 틀렸다.</strong> 검사기는 &quot;빠진 게 없나&quot;만 재고 <strong>&quot;읽을 수 있나&quot;는 안 재고 있었다.</strong></p>
<p>실물을 검사기에 넣고 직접 세어 봤다.</p>
<table>
<thead>
<tr>
<th>무엇</th>
<th>값</th>
<th>왜 통과했나</th>
</tr>
</thead>
<tbody><tr>
<td>「진행」</td>
<td>주제 <strong>6개 · 33줄</strong></td>
<td>주제당 상한(8줄)만 뒀더니 <strong>곱해져서 새어 나갔다</strong> (6×8=48줄까지 통과)</td>
</tr>
<tr>
<td>근거 링크</td>
<td><strong>33/33 불릿</strong>, 본문의 <strong>23%가 URL</strong></td>
<td>분량 경고가 URL 때문에 떴고, 그러면 <strong>줄일 곳을 잘못 가리킨다</strong></td>
</tr>
<tr>
<td>중복</td>
<td>같은 항목이 <strong>4곳</strong>에</td>
<td>「결정·지원 필요」에 올린 것이 「진행」·「확인 필요」·「한 눈에」에 또</td>
</tr>
<tr>
<td>대상</td>
<td><code>상위 보고</code> 인데 개별 업무가 안 접힘</td>
<td>규격에 &quot;개별 업무는 접고&quot; 라고 <strong>적어만 뒀고 검사기가 대상을 안 봤다</strong></td>
</tr>
</tbody></table>
<p>네 가지 다 <strong>우리가 만든 규칙이 놓친 것</strong>이다. 하나씩 본다.</p>
<h3 id="3-1-지역-상한만-두면-곱해진다">3-1. 지역 상한만 두면 곱해진다</h3>
<p>가장 부끄러운 것부터. 주제당 8줄이라는 상한은 있었다. 전체 상한이 없었다.</p>
<pre><code class="language-java">Limits limits = limitsFor(content);

// 주제 수
if (topics.size() &gt; limits.topics()) { … }

// 「진행」 전체 줄 수 — 주제당 상한만 두면 곱해져서 새어 나간다
long progressLines = section.body().stream()
    .filter(line -&gt; !line.text().startsWith(&quot;### &quot;))
    .count();
if (progressLines &gt; limits.progressLines()) {
    problems.add(DraftProblem.at(section.line(),
        &quot;「진행」 이 %d줄입니다(상한 %d줄)&quot;.formatted(progressLines, limits.progressLines()),
        &quot;주제당 상한만으로는 새어 나갑니다 — 전체를 %d줄 안으로 줄여 주세요. 세부는 근거 링크로 넘깁니다.&quot;
            .formatted(limits.progressLines())));
}</code></pre>
<p>일반화하면 이렇다. <strong>분할된 단위마다 상한을 두면 전체 상한이 자동으로 생기지 않는다.</strong> 페이지네이션 크기 제한, 배치 크기 제한, 재시도 횟수 제한 다 같은 모양이다. 항목당 3회 재시도는 항목이 100개면 300회다.</p>
<h3 id="3-2-재는-대상-url은-사람에게-한-글자도-아니다">3-2. 재는 대상: URL은 사람에게 한 글자도 아니다</h3>
<p>분량은 원래 원문 길이(<code>content.length()</code>)로 셌다. 실물에서 근거 링크가 불릿마다 붙어 <strong>본문의 23%가 URL</strong>이 됐다. 그래서 분량 경고는 떴다. 문제는 그 경고를 보고 문장을 지워도 <strong>경고가 안 사라진다</strong>는 것이다.</p>
<pre><code class="language-java">/**
 * 분량은 &lt;b&gt;사람이 읽는 글자&lt;/b&gt;로 잰다 — 근거 링크의 URL 은 빼고 센다.
 *
 * &lt;p&gt;읽는 사람에게는 URL 이 한 글자도 아니므로 그걸 세면 &lt;b&gt;줄일 곳을 잘못 가리킨다.&lt;/b&gt;
 *
 * &lt;p&gt;저장 상한({@code ReportDraft.MAX_CONTENT_LENGTH})은 원문 길이 그대로다. 그쪽은 DB 를
 * 지키는 값이고 이쪽은 읽는 사람을 지키는 값이다.
 */
private static void checkLength(String content, List&lt;DraftProblem&gt; problems) {
    int length = content.lines().map(ReportDraftSpec::plain).mapToInt(String::length).sum();
    …
}</code></pre>
<p>마지막 문단이 이 절의 요지다. <strong>같은 &quot;길이&quot;에 두 개의 상한이 있고, 서로 다른 것을 지킨다.</strong></p>
<ul>
<li><code>MAX_CONTENT_LENGTH</code> (원문 길이) — DB 컬럼을 지킨다. 하드 실패.</li>
<li><code>SOFT_LIMIT_CHARS</code> (URL 뺀 길이) — 읽는 사람을 지킨다. 경고.</li>
</ul>
<p>이걸 하나로 합치려 들면 둘 중 하나가 반드시 틀린다. 상한을 만들 때 물어야 하는 건 &quot;몇으로 할까&quot;가 아니라 <strong>&quot;이 숫자는 무엇을 지키나&quot;</strong> 다.</p>
<p>그리고 <strong>URL 위치는 그대로 뒀다.</strong> 인사팀이 칭찬한 지점이었고(&quot;URL 달아준 것은 좋았음&quot;), 줄 수 상한이 절반으로 줄면서 링크 수도 함께 줄어든다. 증상의 원인처럼 보이는 걸 반사적으로 걷어내지 않았다.</p>
<h3 id="3-3-대상을-안-보는-상한은-상한이-아니다">3-3. 대상을 안 보는 상한은 상한이 아니다</h3>
<p>규격 문서에는 이렇게 적혀 있었다. *&quot;상위 보고는 프로젝트 단위 결론만. 개별 업무는 접는다.&quot;* 검사기는 대상을 읽지 않았다.</p>
<pre><code class="language-java">/**
 * @param topics        「진행」의 주제 수
 * @param progressLines 「진행」 전체 줄 수
 * @param chars         본문 권장 분량
 */
public record Limits(int topics, int progressLines, int chars) { }

/** 상위 보고 — 프로젝트 단위 결론만. 개별 업무는 접는다. */
private static final Limits EXECUTIVE_LIMITS = new Limits(4, 16, 5_000);

/** 팀 주간회의 — 누가 무엇을 하는지가 회의 내용이라 더 받는다. */
private static final Limits TEAM_LIMITS = new Limits(6, 30, 8_000);

/**
 * 제목의 대상으로 상한을 고른다. 대상을 안 적었으면 넉넉한 쪽(팀 회의)으로 본다 —
 * 대상 누락은 이미 따로 지적하므로 여기서 두 번 벌하지 않는다.
 */
public static Limits limitsFor(String content) {
    boolean executive = content.lines()
        .filter(line -&gt; line.startsWith(&quot;# &quot;))
        .findFirst()
        .map(title -&gt; title.contains(Audience.EXECUTIVE.displayLabel()))
        .orElse(false);
    return executive ? EXECUTIVE_LIMITS : TEAM_LIMITS;
}</code></pre>
<p>작은 디테일 하나가 마음에 든다. <strong>대상을 안 적었으면 넉넉한 쪽으로 본다.</strong> 대상 누락은 이미 <code>checkTitle</code>이 지적한다. 한 실수로 두 번 벌하면 지적 목록이 부풀고, 부푼 목록은 안 읽힌다.</p>
<h3 id="3-4-같은-사실을-두-번-쓰지-않는다">3-4. 같은 사실을 두 번 쓰지 않는다</h3>
<p>실물에서 같은 항목이 네 곳에 나왔다. 읽는 사람은 같은 사실을 네 번 읽고, 분량은 그만큼 부푼다.</p>
<pre><code class="language-java">for (Line row : table.rows()) {
    String subject = cell(row.text(), 0);
    String[] words = subject.split(&quot;\\s+&quot;);
    if (words.length &lt; 2) continue;

    // 비교는 첫 두 어절로 한다. 한 어절만 보면 흔한 말에 걸린다
    // (&quot;일본&quot; 은 소속 표기 줄에도 있었다).
    String phrase = words[0] + &quot; &quot; + words[1];
    if (phrase.length() &lt; 4) continue;

    // 「한 눈에」와 「근거」는 뺀다 — 요약하는 자리와 출처를 모으는 자리라,
    // 결정 항목이 거기 다시 나오는 것은 중복이 아니다.
    List&lt;Section&gt; elsewhere = sections.stream()
        .filter(s -&gt; !s.title().startsWith(DECISIONS)
                  &amp;&amp; !s.title().startsWith(SUMMARY)
                  &amp;&amp; !s.title().startsWith(SOURCES))
        .toList();
    …
}</code></pre>
<p>&quot;첫 두 어절&quot;이라는 어림에는 근거가 있다. 결정 항목의 첫 두 어절은 대개 주체나 대상이라 특정적이다. 한 어절만 보면 흔한 말에 걸린다 — 실제로 지역명 한 단어가 소속 표기 줄에 걸렸다.</p>
<p>같은 초안이 지금은 <strong>9건</strong>을 받는다. 주제 6개 · 33줄 · 재료 사정 1건 · 중복 6건.</p>
<hr>
<h2 id="4-살아-있는-스펙--규격-문서가-검사기에서-나온다">4. 살아 있는 스펙 — 규격 문서가 검사기에서 나온다</h2>
<p>여기가 이 설계에서 가장 값을 한 부분이다.</p>
<p>규격은 두 곳에서 쓰인다. 하나는 <strong>문서</strong>(MCP 리소스 <code>myapp://weekly-report/draft-spec</code> — 모델이 읽는다). 하나는 <strong>검사기</strong>(코드). 둘이 갈리면 최악이다. 받는 쪽은 어느 쪽을 믿어야 할지 모른다.</p>
<pre><code class="language-java">/**
 * &lt;p&gt;&lt;b&gt;규격과 검사기가 한 파일에 있는 이유.&lt;/b&gt; 둘이 갈리면 검사기가 지적하는 것과
 * 규격이 말하는 것이 달라진다. 문서 산문은 어댑터가 쓰지만 &lt;b&gt;어휘·상한·골격은 여기가
 * 유일한 출처&lt;/b&gt;이고 어댑터가 이 상수를 치환해 문서를 만든다.
 */
public final class ReportDraftSpec { … }</code></pre>
<p>문서 어댑터는 <code>{{TOPIC_MAX_LINES}}</code> 같은 자리를 상수로 치환한다. 그리고 테스트가 세 가지를 고정한다.</p>
<pre><code class="language-java">@Test
void 치환이_빠진_자리가_없다() {
    assertThat(resource.draftSpec()).doesNotContain(&quot;{{&quot;).doesNotContain(&quot;}}&quot;);
}

@Test
void 골격과_어휘가_검사기와_같은_출처다() {
    String spec = resource.draftSpec();
    // 문서에 손으로 적은 섹션 이름이 하나라도 있으면 상수를 바꿀 때 한쪽만 바뀐다.
    assertThat(spec).contains(ReportDraftSpec.SECTIONS);
    assertThat(spec).contains(ReportDraftSpec.STATES);
    assertThat(spec).contains(ReportDraftSpec.BANNED_WORDS);
    assertThat(spec).contains(String.valueOf(ReportDraftSpec.TOPIC_MAX_LINES));
    …
}

@Test
void 문서에_실린_예시가_검사기를_통과한다() {
    String example = lastMarkdownBlock(resource.draftSpec());

    // 예시가 규격을 어기고 있으면 우리가 틀린 것을 가르치는 셈이다. 그리고 그 예시를
    // 따라 쓴 초안이 곧바로 지적을 받아, 규격을 믿지 않게 된다.
    List&lt;DraftProblem&gt; problems = ReportDraftSpec.check(example);
    assertThat(problems).describedAs(&quot;예시 초안의 지적: %s&quot;, problems).isEmpty();
}</code></pre>
<p><strong>세 번째 테스트가 실제로 버그를 잡았다.</strong> 중복 규칙의 「한 눈에」 예외를 처음 구현에서 빼먹었다. 문서에는 &quot;요약하는 자리라 예외&quot;라고 적어 두고 코드에서 빠뜨렸다. 규격 문서에 실은 예시 초안이 자기 규격에 걸렸고, 이 테스트가 그걸 잡았다.</p>
<p>프롬프트나 스펙 문서를 쓰는 사람이라면 이 패턴 하나만 가져가도 남는다. <strong>문서에 실은 예시를 검증기에 넣어라.</strong> LLM은 지시문보다 예시를 따른다. 예시가 규격을 어기고 있으면 규격을 아무리 잘 써도 소용없다.</p>
<p>한 가지 더. 테스트 주석에 <strong>어떤 피드백에서 온 규칙인지</strong> 남겼다.</p>
<pre><code class="language-java">@Test
void 받은_피드백이_규격에_남아_있다() {
    // 규칙만 적으면 다음 사람이 &quot;왜 이렇게 쓰나&quot; 를 모르고 되돌린다. 받은 말을 그대로 남긴다.
    assertThat(spec).contains(&quot;말투가 너무 AI스러워&quot;);          // 개조식
    assertThat(spec).contains(&quot;상태 집계 표를 넣지 마라&quot;);       // 진행/완료/대기 표
    assertThat(spec).contains(&quot;상태 변경값에 대한 요약은x&quot;);     // 지난주 대비 변화
}</code></pre>
<p>이 테스트가 깨지면 <strong>그 지적이 되돌아온다는 뜻</strong>이다. 규칙만 남기고 근거를 지우면 다음 사람이(혹은 3주 뒤의 내가) &quot;이건 좀 과한데&quot; 하고 되돌린다.</p>
<hr>
<h2 id="5-정리--옮겨-쓸-수-있는-것">5. 정리 — 옮겨 쓸 수 있는 것</h2>
<p>LLM 산출물에 품질 게이트를 붙일 때 쓸 체크리스트로 정리한다.</p>
<p><strong>1) 정형은 규칙으로 재라. LLM 심판을 부르지 마라.</strong>
섹션·줄 수·어휘는 세는 일이다. 심판 모델은 테스트로 고정되지 않고, 고정되지 않는 지적은 무시된다.</p>
<p><strong>2) 위반은 오류가 아니라 값이다.</strong>
막으면 사람이 볼 산출물이 아예 안 나온다. <code>{무엇이, 어느 줄, 어떻게 고치나}</code>를 응답에 실어라. <code>fix</code> 없는 린트 메시지는 절반만 일한다.</p>
<p><strong>3) 검사를 별도 도구로 만들지 마라.</strong>
부르지 않으면 끝이다. 저장·제출 응답에 실으면 건너뛸 수 없다.</p>
<p><strong>4) 재는 대상이 막고 싶은 대상과 같은지 확인하라.</strong>
분량을 원문 길이로 재면 URL을 센다. 같은 이름의 상한이 둘 필요할 수도 있다 — 각 숫자가 무엇을 지키는지 물어라.</p>
<p><strong>5) 지역 상한은 전역 상한을 만들지 않는다.</strong>
항목당 8줄 × 6항목 = 48줄이 통과한다.</p>
<p><strong>6) 문장으로만 적은 규칙은 지켜지지 않는다.</strong>
&quot;상위 보고는 접는다&quot;를 문서에 적어 두면 안 지켜진다. 검사기가 대상을 읽어야 지켜진다.</p>
<p><strong>7) 금지 목록은 짧게 유지하라.</strong>
오탐 몇 번에 무너지는 것은 그 규칙이 아니라 게이트 전체의 신뢰다.</p>
<p><strong>8) 조용히 자르지 마라.</strong>
지적을 8개만 나열하고 끝내면 받는 쪽은 그게 전부인 줄 알고 8개만 고친다. 넘치면 &quot;외 n건&quot;을 함께 말한다.</p>
<pre><code class="language-java">private static final int MAX_LISTED = 8;

if (narrative.size() &gt; MAX_LISTED) {
    problems.add(DraftProblem.of(
        &quot;서술체로 끝나는 줄이 %d건 더 있습니다&quot;.formatted(narrative.size() - MAX_LISTED),
        &quot;행 번호는 앞의 %d건만 적었습니다. 본문 전체를 개조식으로 맞춰 주세요.&quot;.formatted(MAX_LISTED)));
}</code></pre>
<p><strong>9) 스펙 문서의 예시를 검증기에 통과시켜라.</strong>
모델은 지시문보다 예시를 따른다. 그리고 그 테스트가 구현 누락을 잡아 준다.</p>
<p><strong>10) 규칙마다 근거를 남겨라.</strong>
근거 없는 규칙은 되돌려진다.</p>
<hr>
<h2 id="6-남은-것--이-게이트가-못-하는-일">6. 남은 것 — 이 게이트가 못 하는 일</h2>
<p>검사기는 <strong>사실 왜곡을 못 잡는다.</strong> 없는 일을 그럴싸하게 적은 초안은 규격을 완벽히 지킬 수 있다. 그건 근거 링크로 사람이 확인하는 몫으로 남겼고, 그래서 「진행」의 모든 사실 불릿에 링크를 요구한다.</p>
<pre><code class="language-java">/** 「진행」의 사실 불릿에는 근거 링크가 붙어야 한다. */
private static void checkEvidenceLinks(Section section, List&lt;DraftProblem&gt; problems) {
    List&lt;Line&gt; missing = section.body().stream()
        .filter(line -&gt; isBullet(line.text()))
        // 짧은 불릿은 소제목·나열이라 근거를 요구하지 않는다. 사실 서술은 대개 이보다 길다.
        .filter(line -&gt; bulletText(line.text()).length() &gt;= 20)
        .filter(line -&gt; !LINK.matcher(line.text()).find())
        .toList();
    …
}</code></pre>
<p>린터가 잡을 수 있는 것과 잡을 수 없는 것을 구분해서 적어 두는 것도 규격의 일부다. 잡는 척하는 게 안 잡는 것보다 나쁘다.</p>
<p>현재 규격 검사기 테스트는 34건이고, 새 규칙마다 하나씩 붙어 있다. 다음 주에 또 얻어맞으면 그 지적도 여기로 들어온다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[맥북 뚜껑 덮어도 작업 안 멈추게 하는 법 (안전장치 포함)]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EB%A7%A5%EB%B6%81-%EB%9A%9C%EA%BB%91-%EB%8D%AE%EC%96%B4%EB%8F%84-%EC%9E%91%EC%97%85-%EC%95%88-%EB%A9%88%EC%B6%94%EA%B2%8C-%ED%95%98%EB%8A%94-%EB%B2%95-%EC%95%88%EC%A0%84%EC%9E%A5%EC%B9%98-%ED%8F%AC%ED%95%A8</link>
            <guid>https://velog.io/@gitit-sehyeon/%EB%A7%A5%EB%B6%81-%EB%9A%9C%EA%BB%91-%EB%8D%AE%EC%96%B4%EB%8F%84-%EC%9E%91%EC%97%85-%EC%95%88-%EB%A9%88%EC%B6%94%EA%B2%8C-%ED%95%98%EB%8A%94-%EB%B2%95-%EC%95%88%EC%A0%84%EC%9E%A5%EC%B9%98-%ED%8F%AC%ED%95%A8</guid>
            <pubDate>Fri, 14 Aug 2026 01:34:49 GMT</pubDate>
            <description><![CDATA[<p>회사 자리에 전원 연결해두고 무거운 작업(예: Claude Code, 빌드, 장시간 스크립트 등)을 돌려놓은 채 뚜껑을 덮어도 잠들지 않게 하는 설정입니다. 대신 퇴근할 때 끄는 걸 깜빡해도 저녁에 자동으로 안전하게 원상복구되는 장치까지 같이 넣었습니다.</p>
<h2 id="왜-필요한가">왜 필요한가</h2>
<ul>
<li>macOS는 뚜껑을 덮으면 하드웨어적으로 강제 절전에 들어갑니다. <code>caffeinate</code> 같은 명령어는 이 강제 절전을 못 막습니다.</li>
<li><code>sudo pmset -a disablesleep 1</code> 명령어를 쓰면 뚜껑을 덮어도 절전에 들어가지 않게 할 수 있습니다.</li>
<li>다만 이 상태로 계속 두면 (특히 가방처럼 밀폐된 공간에 넣을 경우) 열이 빠져나가지 못해 발열/배터리 소모 문제가 생길 수 있습니다.</li>
<li>그래서 <strong>사무실 책상 위에서, 전원 연결한 상태로만</strong> 쓰는 걸 전제로 하고, 깜빡하고 켜놓은 채 퇴근해도 자동으로 꺼지도록 안전장치(스케줄 작업)를 걸어둡니다.</li>
</ul>
<h2 id="사전-준비">사전 준비</h2>
<ul>
<li>macOS, 터미널(Terminal.app) 사용</li>
<li>관리자 비밀번호 (sudo 명령어에 필요)</li>
</ul>
<hr>
<h2 id="1단계-안전장치-스크립트-만들기">1단계. 안전장치 스크립트 만들기</h2>
<p>터미널에서 아래 명령어를 통째로 복사해서 실행합니다. (다운로드 없이 터미널에서 바로 파일이 생성됩니다.)</p>
<pre><code class="language-bash">sudo mkdir -p /usr/local/bin
sudo tee /usr/local/bin/pmset-safety-off.sh &gt; /dev/null &lt;&lt; &#39;EOF&#39;
#!/bin/bash
# 매일 정해진 시간에 pmset disablesleep을 자동으로 꺼서
# 깜빡하고 켜놓은 채 퇴근하는 걸 방지하는 안전장치 스크립트.
/usr/bin/pmset -a disablesleep 0
echo &quot;$(date &#39;+%Y-%m-%d %H:%M:%S&#39;) disablesleep -&gt; 0 (정상 절전 복구됨)&quot;
EOF
sudo chmod +x /usr/local/bin/pmset-safety-off.sh</code></pre>
<h2 id="2단계-매일-자동-실행되게-등록하기-launchdaemon">2단계. 매일 자동 실행되게 등록하기 (LaunchDaemon)</h2>
<p>기본값은 <strong>매일 저녁 8시(20:00)</strong> 에 자동으로 절전 방지를 꺼주는 설정입니다.</p>
<pre><code class="language-bash">sudo tee /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist &gt; /dev/null &lt;&lt; &#39;EOF&#39;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot; &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.nhn.pmset-safety-off&lt;/string&gt;
    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/bin/bash&lt;/string&gt;
        &lt;string&gt;/usr/local/bin/pmset-safety-off.sh&lt;/string&gt;
    &lt;/array&gt;
    &lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;Hour&lt;/key&gt;
        &lt;integer&gt;20&lt;/integer&gt;
        &lt;key&gt;Minute&lt;/key&gt;
        &lt;integer&gt;0&lt;/integer&gt;
    &lt;/dict&gt;
    &lt;key&gt;RunAtLoad&lt;/key&gt;
    &lt;false/&gt;
    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/var/log/pmset-safety-off.log&lt;/string&gt;
    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/var/log/pmset-safety-off.log&lt;/string&gt;
&lt;/dict&gt;
&lt;/plist&gt;
EOF
sudo chown root:wheel /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist
sudo chmod 644 /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist</code></pre>
<h2 id="3단계-등록-및-테스트">3단계. 등록 및 테스트</h2>
<pre><code class="language-bash">sudo launchctl load -w /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist
sudo launchctl start com.nhn.pmset-safety-off</code></pre>
<p><strong>확인 명령어</strong> (macOS는 이 값을 <code>disablesleep</code>이 아니라 <code>SleepDisabled</code>라는 이름으로 보여줍니다):</p>
<pre><code class="language-bash">pmset -g | grep -i sleepdisabled</code></pre>
<p><code>SleepDisabled 0</code>이 뜨면 정상입니다.</p>
<p>등록이 잘 됐는지 확인:</p>
<pre><code class="language-bash">sudo launchctl list | grep pmset-safety-off</code></pre>
<p><code>com.nhn.pmset-safety-off</code>가 목록에 뜨면 정상 등록된 것입니다.</p>
<p>로그로 실행 이력 확인:</p>
<pre><code class="language-bash">cat /var/log/pmset-safety-off.log</code></pre>
<p><strong>전체 흐름 테스트 (선택)</strong></p>
<pre><code class="language-bash">sudo pmset -a disablesleep 1
pmset -g | grep -i sleepdisabled   # SleepDisabled 1 확인
sudo launchctl start com.nhn.pmset-safety-off
pmset -g | grep -i sleepdisabled   # SleepDisabled 0 으로 바뀌었는지 확인</code></pre>
<h2 id="4단계-매일-켜기-편하게-alias-등록하기">4단계. 매일 켜기 편하게 alias 등록하기</h2>
<p>절전 방지를 켜는 건 자동화하지 않았습니다 (깜빡해도 위험하지 않은 쪽이라 수동이 안전합니다). 대신 짧은 명령어로 켤 수 있게 alias를 등록합니다.</p>
<pre><code class="language-bash">echo &quot;alias sleepoff=&#39;sudo pmset -a disablesleep 1&#39;&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc</code></pre>
<p>등록 확인:</p>
<pre><code class="language-bash">sleepoff</code></pre>
<p>비밀번호 입력 후 에러 없이 넘어가면 정상입니다.</p>
<hr>
<h2 id="매일-사용법">매일 사용법</h2>
<table>
<thead>
<tr>
<th>시점</th>
<th>할 일</th>
</tr>
</thead>
<tbody><tr>
<td>출근해서</td>
<td>터미널에 <code>sleepoff</code> 입력 → 비밀번호 입력. 그날은 뚜껑 덮어도 절전 안 됨</td>
</tr>
<tr>
<td>작업 중</td>
<td>신경 쓸 것 없음</td>
</tr>
<tr>
<td>퇴근할 때</td>
<td>아무것도 안 해도 됨. 그냥 뚜껑 덮고 나가면 됨</td>
</tr>
<tr>
<td>저녁 8시</td>
<td>자동으로 <code>SleepDisabled 0</code>으로 복구됨 (깜빡하고 켜놓은 채 퇴근해도 안전)</td>
</tr>
</tbody></table>
<h2 id="주의사항">주의사항</h2>
<ul>
<li>반드시 <strong>전원 어댑터 연결 상태 + 사무실 책상 위(통풍되는 곳)</strong> 에서만 사용하세요. 가방처럼 밀폐된 공간에 넣고 뚜껑 닫힌 채 이동하면 열이 못 빠져나가 위험합니다.</li>
<li>자동 복구 시간을 8시가 아닌 다른 시간으로 바꾸고 싶다면, 2단계 명령어에서 <code>&lt;integer&gt;20&lt;/integer&gt;</code>(시), <code>&lt;integer&gt;0&lt;/integer&gt;</code>(분) 숫자를 원하는 시간으로 바꿔서 다시 실행한 뒤 아래로 재적용하세요.</li>
</ul>
<pre><code class="language-bash">sudo launchctl unload /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist
sudo launchctl load -w /Library/LaunchDaemons/com.nhn.pmset-safety-off.plist</code></pre>
<h2 id="참고-원리-요약">참고 (원리 요약)</h2>
<ul>
<li><code>caffeinate</code>: 유휴 절전만 막고, 뚜껑 닫힘으로 인한 강제 절전은 못 막음</li>
<li><code>sudo pmset -a disablesleep 1</code>: 뚜껑 닫힘 절전까지 막음 (이 가이드에서 사용하는 방법)</li>
<li>LaunchDaemon: root 권한으로 매일 정해진 시각에 자동 실행되는 macOS 스케줄러. <code>/Library/LaunchDaemons</code>에 등록하면 재부팅 후에도 유지됨</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[측정 가능한 코드 검색 엔진 만들기 (3) — 정답셋의 60%가 오염되어 있었다]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-3-%EC%A0%95%EB%8B%B5%EC%85%8B%EC%9D%98-60%EA%B0%80-%EC%98%A4%EC%97%BC%EB%90%98%EC%96%B4-%EC%9E%88%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-3-%EC%A0%95%EB%8B%B5%EC%85%8B%EC%9D%98-60%EA%B0%80-%EC%98%A4%EC%97%BC%EB%90%98%EC%96%B4-%EC%9E%88%EC%97%88%EB%8B%A4</guid>
            <pubDate>Thu, 06 Aug 2026 14:54:38 GMT</pubDate>
            <description><![CDATA[<h2 id="지난-이야기">지난 이야기</h2>
<p><a href="%EB%A7%81%ED%81%AC">2편</a>에서 GitHub 이슈로 정답셋을 자동 수집하는 규칙을 세웠다. 함정 3개(forward-port 껍데기, 템플릿 오염, 마지막 커밋)를 넘어서, 이슈 하나로 첫 <code>(질의, 정답)</code> 쌍을 손으로 뽑아냈다.</p>
<p>이번엔 그 규칙을 스크립트로 자동화해서 정답셋을 실제로 만들었다. 그리고 만들자마자 <strong>더 큰 함정</strong>을 발견했다. 검수를 안 했으면 이후 모든 숫자가 거짓말이 될 뻔한 함정이다.</p>
<hr>
<h2 id="자동화-500개-이슈-→-67쌍">자동화: 500개 이슈 → 67쌍</h2>
<p>수집 스크립트를 세 조각으로 나눠 만들었다.</p>
<ol>
<li>이슈 목록을 페이지네이션으로 긁고 질의 필터 적용</li>
<li>각 이슈 → 커밋 추적 → 정답 파일 추출</li>
<li>전체 실행해서 JSON 저장</li>
</ol>
<p>돌려보니:</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/c073e44d-b5a9-4cf9-a894-cf4ba294d6c4/image.png" alt=""></p>
<pre><code>500개 조회 → 질의 후보 117개 → 정답셋 67쌍 (채택률 57%)</code></pre><p>흥미로운 건 페이지별 통과율이었다. 최신 이슈(page 1)는 8%인데 오래된 이슈(page 3~5)는 30%가 넘었다. 최신일수록 아직 다른 버전으로 포팅 중이라 forward-port 껍데기가 많기 때문이다.</p>
<p>67쌍. 파이프라인이 도는 걸 확인했으니, 이제 평가로 넘어가면 될까? <strong>아니다.</strong> 여기서 멈추고 정답셋을 눈으로 검수해야 한다. 자동 생성한 데이터는 반드시 오염돼 있기 때문이다.</p>
<hr>
<h2 id="검수-정답이-질문에-새고-있었다">검수: 정답이 질문에 새고 있었다</h2>
<p>가장 먼저 확인한 건 <strong>데이터 리키지</strong>였다. 이슈 본문에 정답 파일의 클래스명이 그대로 들어있는 경우다.</p>
<p>왜 이게 치명적일까? 예를 들어:</p>
<pre><code>이슈 본문: &quot;WebMvcAutoConfiguration에서 NPE가 발생합니다...&quot;
정답 파일: WebMvcAutoConfiguration.java</code></pre><p>이러면 검색 엔진이 <strong>의미를 이해해서 찾은 게 아니라</strong>, 그냥 같은 이름의 파일을 반환하면 맞는다. 검색 문제가 아니라 문자열 매칭 문제로 변질된 것이다. 특히 나중에 키워드 검색(BM25)을 넣으면 이 클래스명을 정확히 매칭해서 거의 100% 맞춰버린다. 그럼 &quot;하이브리드 검색이 좋다!&quot;는 결론이 나오는데, 진짜 검색력이 아니라 <strong>정답 유출</strong> 때문이다.</p>
<p>간단한 스크립트로 세어봤다. 이슈 본문에 정답 클래스명이 있는 쌍이 몇 개인지.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/e8f066c5-055b-4fe0-bb5a-44ce8fe7c5d8/image.png" alt=""></p>
<p><strong>67쌍 중 44쌍(66%).</strong> 충격적인 숫자였다.</p>
<hr>
<h2 id="잠깐-무조건-버리면-안-된다">잠깐, 무조건 버리면 안 된다</h2>
<p>여기서 성급하게 44쌍을 버리면 27쌍만 남는다. 너무 적다. 그래서 이 44개가 진짜 리키지인지 먼저 눈으로 봤다. 본문에서 클래스명이 <strong>어떤 맥락으로</strong> 등장하는지.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/e989dfd9-d549-4936-ad9c-8294d18d7b86/image.png" alt=""></p>
<p>네 종류가 다 달랐다.</p>
<pre><code>#49800 &quot;Component: `WebMvcAutoConfiguration.configureApiVersioningUse()`&quot;
       → 구조화된 템플릿에 정답을 라벨처럼 명시. 최악의 유출.

#50668 &quot;The `InetAddressFilter` introduced in Spring Boot 4.1...&quot;
       → 백틱으로 감싼 정확한 클래스명. 명백한 유출.

#48050 &quot;minimum supported API version (`MinAPIVersion`...&quot;
       → 정답은 ApiVersion인데, &#39;API version&#39;이라는 일반 표현에 걸림. 오탐!

#46665 &quot;one of them is not found in the builder...&quot;
       → 여기 &#39;builder&#39;는 영어 단어. 정답 Builder.java와 우연히 글자만 같음. 오탐!</code></pre><p>내 1차 탐지가 너무 순진했다. <code>Builder</code>, <code>ApiVersion</code> 같은 <strong>흔한 단어</strong>가 일반 영어에 우연히 걸린 오탐이 섞여 있었다.</p>
<p>탐지를 정교하게 고쳤다. 단어 경계 매칭(<code>\b</code>), 백틱 표기 우선, 흔한 이름 별도 처리. 다시 세어보니:</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/eca733d5-0ca8-4513-aab8-ee2275ebd843/image.png" alt=""></p>
<pre><code>강한 리키지 (백틱 정확매칭):  15쌍
중간 (단어경계 매칭):         25쌍
오탐 (흔한 이름):             2쌍
──────────────────────────
실제 리키지: 40/67쌍 (60%)</code></pre><p>오탐은 2개뿐이었다. 40쌍은 진짜였다.</p>
<hr>
<h2 id="이건-버그가-아니라-데이터의-성격이다">이건 버그가 아니라 데이터의 성격이다</h2>
<p>60%가 리키지. 처음엔 데이터가 망했다고 생각했다. 그런데 곱씹어보니 다른 결론이 나왔다.</p>
<p><strong>잘 관리되는 프로젝트일수록 버그 리포트가 정확하다.</strong> &quot;어디가 문제인지&quot;를 명시하는 게 좋은 버그 리포트니까. 스프링 팀은 아예 <code>**Component:** \</code>XxxAutoConfiguration`` 같은 구조화된 표기로 클래스를 지목한다.</p>
<p>즉 이 리키지는 데이터의 결함이 아니라 <strong>본질적 특성</strong>이고, 동시에 &quot;실제 개발자가 클래스명을 언급하는&quot; 현실적인 질의 형태이기도 하다. 실무에서도 &quot;그 AutoConfiguration이 이상해&quot;라고 물어보지 않는가.</p>
<p>그래서 버리는 대신 <strong>마스킹</strong>하기로 했다.</p>
<hr>
<h2 id="대응-두-개의-정답셋">대응: 두 개의 정답셋</h2>
<p>정답 클래스명을 <code>[MASK]</code>로 가린 버전을 따로 만들었다.</p>
<pre><code>data/goldset_v1.json          원본 (클래스명 그대로)
data/goldset_v1_masked.json   마스킹 (42쌍 처리)</code></pre><p>그리고 <strong>두 버전 모두로 평가</strong>한다.</p>
<pre><code>raw 평가:    클래스명이 주어졌을 때 성능 (쉬운 모드)
masked 평가: 이름 빼고 증상 서술만으로 찾는 성능 (진짜 검색력)</code></pre><p>이 대비가 강력한 이유가 있다. <strong>BM25(키워드 검색)는 raw에선 클래스명 매칭으로 치팅하지만, masked에선 못 한다.</strong> 반면 의미 검색(dense)은 masked에서도 증상으로 찾을 수 있다. 그래서 두 정답셋의 성능 차이를 보면 <strong>&quot;하이브리드가 진짜 검색을 잘하는가, 아니면 이름 매칭으로 치팅하는가&quot;</strong>를 분리할 수 있다.</p>
<p>리키지를 발견한 게 오히려 더 좋은 실험 설계로 이어진 것이다.</p>
<p>한 가지는 정직하게 남겨뒀다. 마스킹은 정답 클래스명의 정확 매칭만 처리한다. 파일 경로(<code>Xxx.java#L103</code>)나 정답과 비슷한 다른 클래스명은 잔존할 수 있다. 완벽하게 가리려 하면 질의 본문이 걸레가 되기 때문에, &quot;정확 매칭까지만 마스킹하고 한계를 문서에 기록&quot;하는 선택을 했다.</p>
<hr>
<h2 id="오늘의-교훈">오늘의 교훈</h2>
<p>이번에도 결론은 같다. <strong>데이터를 눈으로 보지 않았으면 몰랐다.</strong></p>
<p>만약 검수 없이 바로 평가로 갔다면 — BM25를 넣었을 때 Hit@10이 확 뛰는 걸 보고 &quot;하이브리드 성공!&quot;이라고 기뻐했을 것이다. 실제로는 정답이 질문에 새서 나온 가짜 상승인데도.</p>
<p>정답셋의 품질은 이후 모든 숫자의 신뢰도를 결정한다. 정답셋이 오염되면 Hit@10 0.9도 거짓말이다. 그래서 이 프로젝트는 &quot;측정할 수 없는 개선은 하지 않는다&quot;를 넘어, <strong>&quot;믿을 수 없는 정답으로는 측정하지 않는다&quot;</strong>를 하나 더 얻었다.</p>
<p>이제 정답셋이 준비됐다(raw / masked 두 벌). 다음 편에서는 드디어 이 정답셋으로 baseline 검색기를 채점한다. 며칠 전 만든 &quot;가장 멍청한 검색기&quot;가 spring-boot 버그 이슈를 얼마나 찾아내는지, <strong>첫 Hit@10</strong>이 나온다.</p>
<hr>
<p><em>다음 편: 첫 Hit@10 측정. &quot;느낌&quot;이 드디어 &quot;숫자&quot;가 됩니다. 그리고 raw와 masked의 점수 차이가 무엇을 말해주는지.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[요청의 72%가 실패했는데 화면에는 에러가 없었다]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EC%9A%94%EC%B2%AD%EC%9D%98-72%EA%B0%80-%EC%8B%A4%ED%8C%A8%ED%96%88%EB%8A%94%EB%8D%B0-%ED%99%94%EB%A9%B4%EC%97%90%EB%8A%94-%EC%97%90%EB%9F%AC%EA%B0%80-%EC%97%86%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@gitit-sehyeon/%EC%9A%94%EC%B2%AD%EC%9D%98-72%EA%B0%80-%EC%8B%A4%ED%8C%A8%ED%96%88%EB%8A%94%EB%8D%B0-%ED%99%94%EB%A9%B4%EC%97%90%EB%8A%94-%EC%97%90%EB%9F%AC%EA%B0%80-%EC%97%86%EC%97%88%EB%8B%A4</guid>
            <pubDate>Thu, 06 Aug 2026 02:48:39 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/4900c512-2773-44a9-9860-8ce8ca9919f8/image.png" alt=""></p>
<p>외부 API 연동에 레이트리밋을 안 걸어 뒀다가 겪은 일. 429 자체는 흔한데, 이번 건은 <strong>실패가 에러로 안 나타나고 틀린 숫자로 나타났다.</strong> 그리고 하마터면 영영 못 찾을 뻔했다.</p>
<hr>
<h2 id="진척률이-전부-0이었다">진척률이 전부 0이었다</h2>
<p>주간 보고서 화면을 만들고 있었다. 업무마다 &quot;이번 주에 얼마나 나아갔는가&quot;를 두 가지로 센다.</p>
<ul>
<li><strong>체크리스트 소진율</strong> — 업무 본문 마크다운의 <code>* [x]</code> 개수</li>
<li><strong>그 주 댓글 수</strong> — 업무 로그에서 이번 주에 달린 댓글</li>
</ul>
<p>연동을 붙이고 열어 보니 업무 60건이 잘 나온다. 제목도 담당자도 상태도 맞다.</p>
<p>그런데 진척률이 <strong>전부 0</strong>이었다. 체크리스트 0/0, 댓글 0건.</p>
<p>처음엔 데이터를 의심했다. 실제로 그 프로젝트는 마감일이 64건 중 2건에만 있을 만큼 필드가 듬성듬성한 곳이라, &quot;체크박스를 아무도 안 쓰나?&quot; 싶었다. 그런데 두레이에서 직접 업무를 열어 보니 체크박스가 멀쩡히 있었다.</p>
<h2 id="로그에-답이-있었다">로그에 답이 있었다</h2>
<pre><code>WARN  두레이 부가 조회 실패 — 해당 항목은 진척 없음으로 둡니다.
      두레이 응답이 정상이 아닙니다(HTTP 429). path=/project/v1/projects/.../logs
WARN  두레이 부가 조회 실패 — ...(HTTP 429)...
WARN  두레이 부가 조회 실패 — ...(HTTP 429)...</code></pre><p>세어 봤다.</p>
<pre><code class="language-bash">$ grep -c &#39;HTTP 429&#39; app.log
86</code></pre>
<p>한 화면을 그리는 데 부가 조회가 120번 나갔고, <strong>그중 86번이 레이트리밋에 걸렸다. 72%다.</strong></p>
<h2 id="왜-에러가-아니라-0이-됐나">왜 에러가 아니라 0이 됐나</h2>
<p>업무 한 건의 진척을 세려면 두 번을 불러야 한다.</p>
<pre><code class="language-java">JsonNode detail = client.postDetail(apiKey, projectId, postId);  // 본문 → 체크박스
JsonNode logs   = client.postLogs(apiKey, projectId, postId);    // 로그 → 그 주 댓글</code></pre>
<p>이건 <strong>부가 정보</strong>다. 본문을 못 받았다고 업무 자체를 화면에서 지우면 안 된다. 목록에는 있는데 화면에서 사라지는 게 더 나쁘니까. 그래서 실패를 이렇게 흡수하도록 만들어 뒀다.</p>
<pre><code class="language-java">/** 있으면 좋은 부가 정보용. 실패를 삼키되 남긴다. */
private JsonNode optional(String apiKey, String path) {
    try {
        return get(apiKey, path);
    } catch (DoorayApiException e) {
        log.warn(&quot;두레이 부가 조회 실패 — 해당 항목은 진척 없음으로 둡니다. {}&quot;, e.getMessage());
        return null;   // ← 여기
    }
}</code></pre>
<p>그리고 매퍼는 <code>null</code> 을 &quot;진척 없음&quot;으로 읽는다.</p>
<pre><code class="language-java">String body = detail == null ? &quot;&quot; : detail.path(&quot;result&quot;).path(&quot;body&quot;).path(&quot;content&quot;).asString(&quot;&quot;);
int[] checks = countCheckboxes(body);   // &quot;&quot; → [0, 0]</code></pre>
<p><strong>설계 자체는 맞다.</strong> 지금도 그대로 두고 있다. 문제는 이 설계가 &quot;부가 정보가 원래 없음&quot;과 &quot;부가 정보를 못 받음&quot;을 <strong>같은 값으로 만든다</strong>는 것이다.</p>
<p>두 상태가 같은 값이 되면, 실패율이 0%든 72%든 화면은 똑같이 생겼다. 사용자는 체크리스트가 0/0이라고 믿는다.</p>
<p>빈 화면은 &quot;뭔가 잘못됐구나&quot; 싶기라도 하다. <strong>틀린 숫자는 그냥 믿게 된다.</strong> 이번 작업에서 제일 아찔했던 지점이다.</p>
<h2 id="서버가-자기-한도를-알려주고-있었다">서버가 자기 한도를 알려주고 있었다</h2>
<p><code>Retry-After</code> 를 찾으려고 헤더를 찍어 봤다가 다른 걸 발견했다.</p>
<pre><code class="language-bash">$ curl -sD- -H &quot;Authorization: dooray-api $TOKEN&quot; \
    &quot;https://api.dooray.com/project/v1/projects?member=me&amp;size=1&quot; | grep -i ratelimit

x-ratelimit-remaining: 19
x-ratelimit-requested-tokens: 1
x-ratelimit-burst-capacity: 20
x-ratelimit-replenish-rate: 5</code></pre>
<p><strong>버킷 20개, 초당 5개 보충.</strong> 전형적인 토큰 버킷이고, 서버가 한도를 그대로 말해 주고 있었다.</p>
<p>나는 동시성 8로 돌리고 있었다. 응답이 100<del>200ms면 초당 40</del>80번이 나간다. 버킷 20개는 <strong>0.5초면 빈다.</strong> 그 뒤로는 초당 5개만 통과하고 나머지는 전부 429다. 86/120이라는 숫자가 정확히 설명된다.</p>
<p>문서를 뒤지기 전에 응답 헤더부터 볼 것. 요즘 API는 대체로 자기 한도를 헤더로 말해 준다. 나는 동시성을 4로 낮췄다 6으로 올렸다 하며 감으로 맞출 뻔했다.</p>
<h2 id="토큰-버킷으로-누르기">토큰 버킷으로 누르기</h2>
<p>서버가 말한 값을 그대로 기본값으로 삼았다. 지어낸 숫자가 아니라는 게 중요하다 — 나중에 두레이가 한도를 바꾸면 헤더를 다시 보고 숫자만 고치면 된다.</p>
<pre><code class="language-java">public void acquire() throws InterruptedException {
    while (true) {
        long sleepMillis;
        synchronized (this) {
            refill();
            if (available &gt;= 1.0) {
                available -= 1.0;
                return;
            }
            sleepMillis = (long) Math.ceil((1.0 - available) / permitsPerSecond * 1000);
        }
        Thread.sleep(Math.max(1, sleepMillis));   // ← 락 밖에서 잔다
    }
}</code></pre>
<p>구현에서 한 가지만 조심하면 된다. <strong>기다림은 <code>synchronized</code> 블록 밖에서</strong> 해야 한다. 안에서 자면 보충된 토큰을 다른 스레드가 못 가져가고 줄이 통째로 멈춘다. 제한이 아니라 직렬화가 된다.</p>
<p>동시성 제한(<code>fetch-concurrency</code>)과 속도 제한은 <strong>다른 것을 막는다</strong>는 것도 뒤늦게 정리됐다. 동시성은 &quot;한 번에 몇 개&quot;, 속도는 &quot;초당 몇 개&quot;다. 응답이 빠르면 동시성 2로도 초당 20번이 나간다. 동시성만 낮추는 건 대증요법이었다.</p>
<p>여기에 429 재시도(지수 백오프)를 남겼다. 제한을 걸었는데도 재시도가 필요한 이유는, 같은 토큰을 다른 파드나 사용자의 다른 탭이 함께 쓰면 우리 쪽 계산만으로는 버킷을 다 못 지키기 때문이다.</p>
<p><strong>429가 0이 됐다.</strong> 진척률도 제대로 나왔다.</p>
<h2 id="그런데-이번엔-24초">그런데 이번엔 24초</h2>
<p>초당 5회 × 업무 60건 × 2회 = <strong>24초</strong>.</p>
<p>정확해졌지만 못 쓰는 화면이 됐다. 캐시를 붙였다.</p>
<p>TTL은 안 썼다. &quot;몇 분이 적당한가&quot;를 근거 없이 정해야 하고, 그 몇 분 동안은 바뀐 값을 못 본다. 대신 키를 이렇게 잡았다.</p>
<pre><code>owner | kind | postId | updatedAt</code></pre><p>업무 본문이나 댓글이 바뀌면 두레이가 그 업무의 <code>updatedAt</code> 을 올린다. 그러면 <strong>키가 달라져서 자연히 새로 받는다.</strong> 만료 시간을 정할 필요가 없고, 바뀐 값을 늦게 보는 일도 없다. 내용으로 주소를 만드는 방식의 캐시 버전인 셈이다.</p>
<p>두 가지를 더 신경 썼다.</p>
<p><strong>소유자를 키에 넣는다.</strong> 개인 API 토큰으로 받은 응답이라 같은 업무라도 사람마다 볼 수 있는지가 다르다. 소유자를 빼면 A가 받아 둔 내용을 B가 캐시로 보게 된다. 성능 최적화가 권한 우회가 되는 경로다.</p>
<p><strong>실패는 캐시하지 않는다.</strong> 이건 바로 아래 이야기와 이어진다.</p>
<h2 id="캐시를-먼저-넣었으면-못-찾았다">캐시를 먼저 넣었으면 못 찾았다</h2>
<p>여기서 등이 서늘해졌다.</p>
<p>작업 순서가 이랬다.</p>
<ol>
<li>연동을 붙인다</li>
<li>호출 수와 소요를 로그에 남긴다</li>
<li>돌려 보고 재 본다 ← <strong>429 발견</strong></li>
<li>레이트리밋을 건다</li>
<li>느려진 걸 캐시로 해결한다</li>
</ol>
<p>만약 2·3을 건너뛰고 &quot;어차피 캐시 필요하지&quot; 하며 5를 먼저 했다면 어땠을까.</p>
<p>첫 조회에서 86건이 429로 실패한다. 실패한 응답을 캐시에 담았다면 그 업무들은 <strong>진척 0인 채로 캐시에 눌러앉는다.</strong> 두 번째 조회부터는 429가 아예 안 뜬다 — 캐시가 맞으니까. 로그도 조용하다. 화면은 빠르고, 숫자는 틀렸고, 증거는 사라진다.</p>
<p>그때는 &quot;체크박스를 아무도 안 쓰나 보다&quot;로 결론 내고 넘어갔을 것이다.</p>
<p>그래서 캐시에 이 규칙을 박아 뒀다.</p>
<pre><code class="language-java">JsonNode loaded = loader.get();
if (loaded == null) {
    return null;      // 실패는 담지 않는다 — 담으면 그 업무는 계속 진척 0으로 남는다
}</code></pre>
<p><strong>재기 전에 고치지 말 것.</strong> 최적화는 종종 증상을 없애는 게 아니라 증거를 없앤다.</p>
<h2 id="결과">결과</h2>
<p>업무 61건, 부가 조회 122회 기준.</p>
<table>
<thead>
<tr>
<th></th>
<th>처음</th>
<th>최종</th>
</tr>
</thead>
<tbody><tr>
<td>429 실패</td>
<td>86 / 120 (72%)</td>
<td><strong>0</strong></td>
</tr>
<tr>
<td>첫 조회</td>
<td>9.4s (진척 데이터 유실)</td>
<td>24.1s (정상)</td>
</tr>
<tr>
<td>재조회</td>
<td>9.4s</td>
<td><strong>2.6s</strong></td>
</tr>
</tbody></table>
<p>첫 조회 24초는 아직 숙제다. 개인 토큰당 초당 5회가 상한이라 지금 구조에서는 더 줄일 수 없다. 서비스 계정을 받으면 미리 받아 두는 쪽이 열린다.</p>
<p>다음 사람이 재보고 판단할 수 있게 로그를 남겨 뒀다.</p>
<pre><code>INFO  두레이 업무 조회 완료 — 61건, 상세·댓글 대상 122회, 캐시 122건, 2616ms</code></pre><hr>
<h2 id="다음엔-이렇게-하려고-한다">다음엔 이렇게 하려고 한다</h2>
<p>이번에 429를 찾은 건 운이 좋았다. 실패를 삼키면서도 <code>log.warn</code> 을 남겨 뒀고, 마침 진척률이 <strong>전부</strong> 0이라 눈에 띄었다. 절반만 실패했으면 못 봤을 것이다.</p>
<p>그래서 규칙 하나를 만들었다.</p>
<p><strong>실패를 삼킬 거면, 건별 로그가 아니라 합계를 남긴다.</strong></p>
<pre><code class="language-java">// 이것만으로는 부족하다 — grep -c 를 해야 보인다
log.warn(&quot;부가 조회 실패 — 진척 없음으로 둡니다. {}&quot;, e.getMessage());

// 요청이 끝날 때 한 줄 더
log.warn(&quot;부가 조회 {}회 중 {}회 실패 — 진척 숫자가 실제보다 낮습니다.&quot;, total, failed);</code></pre>
<p>건별 WARN은 &quot;뭔가 하나 실패했나 보다&quot;로 읽히고 그냥 넘어가게 된다. <strong><code>122회 중 86회 실패</code></strong> 는 안 넘어간다. 나중에는 이걸 메트릭으로 올려서 실패율이 임계값을 넘으면 알림이 가게 할 생각이다.</p>
<p>삼키는 것 자체는 여전히 맞다고 본다. 부가 정보 하나 때문에 화면 전체를 비울 이유는 없다. 다만 <strong>삼킨 것의 총량은 어딘가에 반드시 남아야 한다.</strong> 그게 없으면 조용히 틀린 화면이 된다.</p>
<blockquote>
<p>아래는 기존 글 &quot;요청의 72%가 실패했는데 화면에는 에러가 없었다&quot; 의 <strong>끝에 덧붙일 후일담</strong>이다.
<code>## 다음엔 이렇게 하려고 한다</code> 섹션 <strong>뒤에</strong> 붙이면 된다.</p>
</blockquote>
<hr>
<h2 id="후일담--프로덕션에서-같은-모양으로-다시-실패했다">후일담 — 프로덕션에서 같은 모양으로 다시 실패했다</h2>
<p>이 글을 쓰고 배포했다. 그리고 며칠 뒤 같은 자리에서 다시 막혔다.</p>
<p>캘린더는 나오는데 주간 보고서와 팀원 보드가 <strong>빈 화면</strong>이었다. 에러는 없었다. 또 그 모양이다.</p>
<p>로그를 봤다.</p>
<pre><code>두레이 업무 조회 완료 — 126건, 상세·댓글 252회, 81869ms
두레이 업무 조회 완료 — 126건, 상세·댓글 252회, 82223ms
두레이 업무 조회 완료 —  61건, 상세·댓글 122회, 60391ms
두레이 일정 조회 완료 —  13건, 월 구간   3회, 25132ms</code></pre><p>로컬에서 24초였던 게 <strong>82초</strong>가 돼 있었다. 그리고 프론트 컨테이너의 nginx 설정에 이게 있었다.</p>
<pre><code class="language-nginx">proxy_read_timeout 60s;</code></pre>
<p>60초를 넘긴 업무 조회 셋은 잘렸고, 25초였던 캘린더만 살아남았다. &quot;캘린더는 되는데 나머지는 안 뜬다&quot;가 정확히 이거였다.</p>
<p>백엔드는 클라이언트가 끊긴 뒤에도 82초를 다 채워 일했다. 그래서 화면을 새로고침하면 그때는 잘 나왔다 — 캐시가 데워져서. <strong>고쳐진 게 아니라 데워진 것</strong>이었는데, 그 둘이 화면에서 똑같이 생겼다.</p>
<h3 id="왜-24초가-82초가-됐나">왜 24초가 82초가 됐나</h3>
<p>화면 여러 개가 동시에 열렸다. 각자 조회를 시작했고, 캐시가 비어 있으니 전부 미스가 났고, 하나뿐인 레이트리미터(초당 5회)에 다 같이 줄을 섰다.</p>
<pre><code>실제 나간 호출  252 + 252 + 122 + 122 + 252 + 252 ≈ 1,252회
필요한 고유 건수                                  ≈   254회</code></pre><p><strong>5배를 중복으로 불렀다.</strong> 결과가 틀리진 않는다. 그냥 5배 느릴 뿐이고, 그 느림이 게이트웨이 타임아웃을 넘겨 다시 빈 화면이 됐다.</p>
<p>캐시를 만들면서 내가 주석에 이렇게 써 뒀었다.</p>
<blockquote>
<p><code>loader</code> 는 락 밖에서 부른다. 안에서 부르면 느린 HTTP 호출 하나가 캐시 전체를 막아, 병렬로 돌리는 의미가 사라진다. <strong>그 대가로 같은 키를 동시에 두 번 부를 수 있는데, 같은 응답을 두 번 받을 뿐이라 결과는 같다.</strong></p>
</blockquote>
<p>&quot;두 번&quot;이 아니었다. 동시 요청 수만큼이었다.</p>
<p>트레이드오프를 인지하고 적어 두기까지 했는데도 <strong>규모를 틀렸다.</strong> 나는 &quot;두 스레드가 우연히 겹치는 경우&quot;를 상상했고, 실제로는 &quot;화면 여섯 개가 동시에 뜨는 경우&quot;였다. 주석을 쓸 때 머릿속에 있던 그림과 프로덕션의 그림이 달랐다.</p>
<h3 id="single-flight">single-flight</h3>
<p>고치는 건 간단했다. 같은 키를 이미 부르고 있으면 새로 부르지 말고 그 결과를 기다리면 된다. 직접 짜다가 Caffeine 으로 갈아탔다.</p>
<pre><code class="language-java">cache.get(key, k -&gt; loader.get());   // 같은 키에 대해 매핑 함수를 동시에 한 번만 실행한다</code></pre>
<p>이 한 줄이 내가 <code>CompletableFuture</code> 로 40줄 짜던 것이다. LRU·TTL·&quot;null 은 담지 않음&quot;까지 전부 같은 성질이라, 손으로 만든 걸 지웠다.</p>
<h3 id="ttl-이야기-정정">TTL 이야기 정정</h3>
<p>이 글 앞에서 &quot;TTL 은 안 썼다, 키에 <code>updatedAt</code> 을 넣어 자기 무효화한다&quot;고 썼다. 그건 <strong>업무 상세·댓글에 대해서만</strong> 맞는 말이었다.</p>
<p>느린 이유를 더 파 보니 그 앞단이 남아 있었다. 프로젝트 목록·업무 목록 호출은 캐시가 없어서 매 요청마다 나갔고, 하나에 1~1.8초씩 걸렸다. 캐시가 다 맞아도 요청이 2.6초 밑으로 안 내려간 이유가 이것이었다.</p>
<p>그런데 목록 응답에는 <code>updatedAt</code> 같은 게 없다. &quot;이 목록이 언제 바뀌었나&quot;를 알 방법이 응답 안에 없으니 자기 무효화가 성립하지 않는다. 결국 짧은 TTL(60초)을 썼다.</p>
<p>정리하면 규칙은 이렇게 갈린다.</p>
<ul>
<li><strong>응답이 자기 버전을 들고 있으면</strong> — 그걸 키에 넣는다. TTL 불필요, 바뀐 값을 늦게 보는 일 없음</li>
<li><strong>없으면</strong> — TTL 을 쓸 수밖에 없다. 대신 그 값은 &quot;정확성과 맞바꾸는 창&quot;이므로, 무엇을 흡수하려는 건지 근거를 적어 둔다(우리는 &quot;체크 연타와 화면 동시 진입&quot;)</li>
</ul>
<p>TTL 을 안 쓴 게 잘한 선택이었다기보다, <strong>안 써도 되는 데이터였던 것</strong>에 가깝다.</p>
<h3 id="결과-1">결과</h3>
<table>
<thead>
<tr>
<th></th>
<th>처음</th>
<th>429 고친 뒤</th>
<th>스탬피드까지 고친 뒤</th>
</tr>
</thead>
<tbody><tr>
<td>429</td>
<td>86/120</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>첫 조회</td>
<td>9.4s (숫자 틀림)</td>
<td>82s (잘림)</td>
<td>3.0s</td>
</tr>
<tr>
<td>재조회</td>
<td>9.4s</td>
<td>2.6s</td>
<td>0.02s</td>
</tr>
</tbody></table>
<h3 id="이-글의-결론이-나한테-돌아왔다">이 글의 결론이 나한테 돌아왔다</h3>
<p>앞에서 이렇게 썼다.</p>
<blockquote>
<p>조용한 실패가 제일 비싸다. 삼킬 거면 반드시 남겨야 한다.</p>
</blockquote>
<p>두 번째 실패도 같은 모양이었다. HTTP 200 도 아니고 아예 <strong>응답이 없는데도</strong> 화면은 그냥 빈 목록이었다. 게이트웨이가 끊은 건 백엔드 로그에 안 남고, 프론트는 실패를 화면에 안 그리고 있었다(팀원 보드는 에러 표시가 아예 없었다).</p>
<p>그리고 찾은 방법도 같았다. 로그에 호출 수와 소요를 남겨 뒀기 때문에 <code>82초</code>, <code>252회</code> 라는 숫자를 볼 수 있었다. 그게 없었으면 &quot;알파가 좀 느리네&quot; 로 끝났을 것이다.</p>
<p>한 가지 배운 게 더 있다. <strong>&quot;고쳤다&quot;와 &quot;지금은 된다&quot;를 구분해야 한다.</strong> 새로고침하니 잘 나오길래 잠깐 넘어갈 뻔했는데, 그건 캐시가 데워진 상태였을 뿐이고 재배포하면 그대로 돌아왔을 것이다. 화면이 잘 나온다는 사실은 생각보다 약한 증거다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[헬프데스크 접수를 GitHub 이슈로 자동 등록하기 — 실패해도 신고를 버리지 않는 설계]]></title>
            <link>https://velog.io/@gitit-sehyeon/%ED%97%AC%ED%94%84%EB%8D%B0%EC%8A%A4%ED%81%AC-%EC%A0%91%EC%88%98%EB%A5%BC-GitHub-%EC%9D%B4%EC%8A%88%EB%A1%9C-%EC%9E%90%EB%8F%99-%EB%93%B1%EB%A1%9D%ED%95%98%EA%B8%B0-%EC%8B%A4%ED%8C%A8%ED%95%B4%EB%8F%84-%EC%8B%A0%EA%B3%A0%EB%A5%BC-%EB%B2%84%EB%A6%AC%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@gitit-sehyeon/%ED%97%AC%ED%94%84%EB%8D%B0%EC%8A%A4%ED%81%AC-%EC%A0%91%EC%88%98%EB%A5%BC-GitHub-%EC%9D%B4%EC%8A%88%EB%A1%9C-%EC%9E%90%EB%8F%99-%EB%93%B1%EB%A1%9D%ED%95%98%EA%B8%B0-%EC%8B%A4%ED%8C%A8%ED%95%B4%EB%8F%84-%EC%8B%A0%EA%B3%A0%EB%A5%BC-%EB%B2%84%EB%A6%AC%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Fri, 31 Jul 2026 05:17:42 GMT</pubDate>
            <description><![CDATA[<p>  사내 리더용 HR 대시보드에 헬프데스크(문제 신고) 탭이 있다. 그런데 신고가 그 탭에만 쌓였다. 처리하는 사람이 목록을 보고 이슈를 직접 만들어 옮겨야 했고, 당연히 누락됐다.</p>
<p>  접수 시점에 GitHub 이슈를 자동으로 만들고 티켓에 링크를 붙이기로 했다. 기능 자체는 <code>POST /repos/{owner}/{repo}/issues</code> 한 방이라 간단한데, 정작 고민이 붙은 곳은 <strong>실패를 어떻게 다룰 것인가</strong>였다.</p>
<h2 id="먼저-정한-것-4개">먼저 정한 것 4개</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>결정</th>
</tr>
</thead>
<tbody><tr>
<td>이슈를 만들 저장소</td>
<td>제품 저장소에 그대로. 개발 이슈와 섞이므로 <code>help-desk</code> 라벨로 구분</td>
</tr>
<tr>
<td>토큰</td>
<td>사내 시크릿 관리(SKM)에서 조회, 로컬은 프로퍼티 fallback</td>
</tr>
<tr>
<td>동기화 방향</td>
<td>단방향(접수 → 이슈). 이슈를 닫아도 티켓 상태는 그대로</td>
</tr>
<tr>
<td>등록 실패 시</td>
<td><strong>접수는 성공</strong>시키고 실패만 기록</td>
</tr>
</tbody></table>
<p>  마지막 항목이 이 글의 주제다.</p>
<h2 id="트랜잭션을-걸면-안-된다">트랜잭션을 걸면 안 된다</h2>
<p>  접수는 이제 단일 저장이 아니다. 이슈 본문에 접수 번호를 실어야 하니 저장이 먼저 끝나야 하고, 그다음 외부 호출, 그다음 링크 갱신. 3단계다.</p>
<p>  &quot;3단계니까 트랜잭션으로 묶어야지&quot;가 첫 반응이었는데, 묶으면 안 된다.</p>
<ol>
<li><p><strong>외부 호출이 DB 커넥션을 붙잡는다.</strong> GitHub이 느려지는 동안 커넥션 풀이 말라간다.</p>
</li>
<li><p><strong>등록 실패가 접수를 롤백한다.</strong> 토큰이 만료됐거나, GHE가 502를 주거나, 누가 라벨을 지웠다는 이유로 사용자가 방금 쓴 신고가 사라진다.</p>
<p>2번이 핵심이다. 이슈 등록은 <strong>우리 사정이 아니라 남의 시스템 사정</strong>으로 실패한다. 그걸로 사용자 입력을 버리는 건 최악의 트레이드오프다.</p>
<pre><code class="language-java">public HelpDeskTicket create(Command command) {
 HelpDeskTicket saved = repository.save(HelpDeskTicket.newTicket(...));

 if (!issueGateway.isEnabled()) {
     return saved;
 }
 return linkIssue(saved, createIssueLink(saved));
}

private HelpDeskIssueLink createIssueLink(HelpDeskTicket saved) {
 try {
     var issue = issueGateway.create(saved);
     return HelpDeskIssueLink.synced(issue.number(), issue.url());
 } catch (RuntimeException e) {
     // 네트워크·인증·권한·응답형식 — 사용자 입장에선 &quot;이슈가 안 만들어졌다&quot; 하나다.
     log.warn(&quot;티켓 {} 의 이슈 등록 실패 — 접수는 유지된다.&quot;, saved.id(), e);
     return HelpDeskIssueLink.failed();
 }
}</code></pre>
<p>예외 종류를 가리지 않고 잡는다. 보통은 나쁜 습관이지만 여기선 의도적이다. 사용자에게 보여줄 건 &quot;실패했다&quot; 한 가지이고, 원인은 스택트레이스로 남긴다.</p>
<h2 id="상태가-3개여야-하는-이유">상태가 3개여야 하는 이유</h2>
<p>처음엔 <code>이슈번호 == null</code> 로 판단하려 했다. 안 된다. 그러면 이 두 상황이 구분되지 않는다.</p>
</li>
</ol>
<ul>
<li><p>연동을 <strong>끈</strong> 환경의 티켓</p>
</li>
<li><p>등록을 시도했다가 <strong>실패한</strong> 티켓</p>
<p>전자에 &quot;이슈 등록 실패&quot;를 띄우면 잘못된 경고고, 후자를 조용히 넘기면 아무도 안 보는 신고가 쌓인다. 그래서 상태를 3개로 뒀다.</p>
<pre><code class="language-java">public enum IssueSyncStatus {
  NOT_LINKED,  // 연동 꺼짐 또는 시도 전
  SYNCED,      // 성공 — 이때만 번호·URL 이 있다
  FAILED       // 시도했지만 실패. 접수는 유지된다
}</code></pre>
<h2 id="상태와-데이터를-한-값으로-묶기">상태와 데이터를 한 값으로 묶기</h2>
<p><code>status</code>, <code>number</code>, <code>url</code> 세 칸을 나란히 두면 이런 조합이 만들어진다.</p>
</li>
<li><p><code>SYNCED</code> 인데 번호가 없다 → 화면이 클릭 안 되는 링크를 그린다</p>
</li>
<li><p><code>FAILED</code> 인데 URL이 있다 → 실패 배지 옆에 링크가 뜬다</p>
<p>둘 다 예외를 던지지 않고 <strong>조용히 이상하게 렌더링된다</strong>. 그게 제일 나쁘다. 그래서 값 객체로 묶고 생성 시점에 조합을 검증했다.</p>
<pre><code class="language-java">public record HelpDeskIssueLink(IssueSyncStatus status, Integer number, String url) {

  public HelpDeskIssueLink {
      boolean hasIssue = number != null || url != null;
      if (status == IssueSyncStatus.SYNCED) {
          if (number == null || url == null || url.isBlank()) {
              throw new IllegalArgumentException(&quot;SYNCED 는 번호와 URL 을 모두 가져야 합니다.&quot;);
          }
      } else if (hasIssue) {
          throw new IllegalArgumentException(&quot;%s 는 번호·URL 을 가질 수 없습니다.&quot;.formatted(status));
      }
  }

  public static HelpDeskIssueLink synced(int number, String url) { ... }
  public static HelpDeskIssueLink failed() { ... }
  public static HelpDeskIssueLink notLinked() { ... }
}</code></pre>
<p>같은 규칙을 DB에도 걸었다. 도메인이 막아도 수동 UPDATE나 마이그레이션으로 들어올 수 있다.</p>
<pre><code class="language-sql">ALTER TABLE help_desk_ticket
  ADD CONSTRAINT help_desk_ticket_issue_link_chk CHECK (
      (issue_sync = &#39;SYNCED&#39; AND github_issue_number IS NOT NULL AND github_issue_url IS NOT NULL)
      OR (issue_sync &lt;&gt; &#39;SYNCED&#39; AND github_issue_number IS NULL AND github_issue_url IS NULL)
  );</code></pre>
<p>URL을 번호로 조립하지 않고 그대로 저장한 것도 의도다. 저장소가 옮겨지거나 호스트가 바뀌어도 과거 티켓의 링크가 유효해야 한다.</p>
<h2 id="사용자-입력을-이슈-본문에-넣을-때">사용자 입력을 이슈 본문에 넣을 때</h2>
<p>이건 만들고 나서 알아챈 함정이다. GitHub은 이슈 본문의 <code>@이름</code>을 멘션으로, <code>#123</code>을 이슈 참조로 확장한다. 사용자가 신고 내용에 이렇게 쓰면?</p>
<blockquote>
<p>@channel 님 확인 부탁드립니다 #123 관련입니다</p>
</blockquote>
</li>
<li><p><em>엉뚱한 사람에게 알림이 가고, 관계없는 이슈에 역참조가 붙는다.*</em> 사용자는 그냥 텍스트로 쓴 것인데.</p>
<p>코드 블록 안에서는 확장되지 않는다. 게다가 붙여넣은 로그의 공백·들여쓰기도 그대로 보존되니 버그 리포트 본문에는 오히려 적합하다. 그래서 사용자 입력을 통째로 코드 블록에 넣었다.</p>
<p>문제는 사용자가 <strong>마크다운 코드블록째로 붙여넣는 경우</strong>다. 울타리가 같으면 블록이 중간에 닫힌다. 내용 안의 가장 긴 백틱 연속보다 하나 더 긴 울타리를 계산해서 쓴다.</p>
<pre><code class="language-java">private static String fenced(String content) {
   int longestRun = 0;
   int run = 0;
   for (char c : content.toCharArray()) {
       run = c == &#39;`&#39; ? run + 1 : 0;
       longestRun = Math.max(longestRun, run);
   }
   String fence = &quot;`&quot;.repeat(Math.max(3, longestRun + 1));
   return fence + &quot;text\n&quot; + content + &quot;\n&quot; + fence;
}</code></pre>
<p>제목에도 함정이 하나 있었다. 접수 번호를 <code>#12</code> 로 쓰려다 말았다 — 본문이라면 GitHub이 12번 이슈 링크로 바꿔버려 엉뚱한 곳을 가리킨다. 제목에선 확장되지 않지만 사람이 그렇게 읽으니 <code>[헬프데스크 12]</code> 로 통일했다.</p>
<h2 id="out-포트를-repository와-gateway로-나눴다">out 포트를 repository와 gateway로 나눴다</h2>
<p>클린 아키텍처로 짜면서 out 포트를 다 <code>domain/repository</code> 에 두고 있었는데, 이슈 등록을 거기 넣으려니 어색했다.</p>
</li>
<li><p><strong>repository</strong>: 우리 데이터를 우리가 보관하는 곳. 실패는 예외 상황이다</p>
</li>
<li><p><strong>gateway</strong>: 남의 시스템에 요청을 보내는 것. <strong>실패가 정상 경로에 포함되고</strong>, 타임아웃이 필요하고, 재시도 의미가 다르다</p>
<p>성격이 다르면 다루는 방법도 다르다. 같은 패키지에 두면 그 차이가 흐려져서 <code>domain/gateway</code> 로 나눴다. 의존 방향은 동일하게 안쪽으로만.</p>
<pre><code>adapter/in/web → application/usecase → domain/{model, repository, gateway}
                                            ↑
                        infrastructure/{persistence, github} 가 구현</code></pre><p>타임아웃은 연결 3초 / 응답 5초로 짧게 잡았다. 접수 요청 안에서 동기로 호출하므로, GHE가 느려질 때 사용자의 신고 등록이 같이 늦어지면 안 된다.</p>
<h2 id="테스트에서-배운-것-두-가지">테스트에서 배운 것 두 가지</h2>
<h3 id="1-어댑터는-목킹하면-정작-틀리는-부분을-건너뛴다">1. 어댑터는 목킹하면 정작 틀리는 부분을 건너뛴다</h3>
<p>HTTP 클라이언트를 목킹해서 &quot;<code>create()</code> 를 호출했는가&quot;를 검증하는 테스트를 쓸 뻔했다. 그런데 이 클래스의 버그는 그런 데서 안 나온다.</p>
</li>
<li><p>URL 경로를 틀렸다</p>
</li>
<li><p>응답 필드가 스네이크케이스(<code>html_url</code>)라 매핑이 안 붙는다</p>
</li>
<li><p>본문에 멘션이 살아 있다</p>
<p>전부 <strong>직렬화·경로 층</strong>의 문제고, 클라이언트를 목킹하면 그 층을 통째로 건너뛴다. 그래서 루프백에 작은 HTTP 서버를 띄우고 보낸 요청을 그대로 붙잡았다. 의존성 추가도 필요 없다.</p>
<pre><code class="language-java">server = HttpServer.create(new InetSocketAddress(&quot;127.0.0.1&quot;, 0), 0);
server.createContext(&quot;/&quot;, exchange -&gt; {
  received.add(new RecordedRequest(
      exchange.getRequestMethod(),
      exchange.getRequestURI().getPath(),
      new String(exchange.getRequestBody().readAllBytes(), UTF_8)));
  // 준비된 응답 반환
});</code></pre>
<p><code>html_url</code> 매핑 누락은 실제로 이 테스트에서 걸렸다.</p>
<h3 id="2-rls가-check-제약을-가려서-무력한-테스트가-통과했다">2. RLS가 CHECK 제약을 가려서, 무력한 테스트가 통과했다</h3>
<p>이게 이번에 가장 배운 점이다.</p>
</li>
</ul>
<ul>
<li><p><code>SYNCED</code> 인데 번호가 없다 → 화면이 클릭 안 되는 링크를 그린다</p>
</li>
<li><p><code>FAILED</code> 인데 URL이 있다 → 실패 배지 옆에 링크가 뜬다</p>
<p>둘 다 예외를 던지지 않고 <strong>조용히 이상하게 렌더링된다</strong>. 그게 제일 나쁘다. 그래서 값 객체로 묶고 생성 시점에 조합을 검증했다.</p>
<pre><code class="language-java">public record HelpDeskIssueLink(IssueSyncStatus status, Integer number, String url) {

  public HelpDeskIssueLink {
      boolean hasIssue = number != null || url != null;
      if (status == IssueSyncStatus.SYNCED) {
          if (number == null || url == null || url.isBlank()) {
              throw new IllegalArgumentException(&quot;SYNCED 는 번호와 URL 을 모두 가져야 합니다.&quot;);
          }
      } else if (hasIssue) {
          throw new IllegalArgumentException(&quot;%s 는 번호·URL 을 가질 수 없습니다.&quot;.formatted(status));
      }
  }

  public static HelpDeskIssueLink synced(int number, String url) { ... }
  public static HelpDeskIssueLink failed() { ... }
  public static HelpDeskIssueLink notLinked() { ... }
}</code></pre>
<p>같은 규칙을 DB에도 걸었다. 도메인이 막아도 수동 UPDATE나 마이그레이션으로 들어올 수 있다.</p>
<pre><code class="language-sql">ALTER TABLE help_desk_ticket
  ADD CONSTRAINT help_desk_ticket_issue_link_chk CHECK (
      (issue_sync = &#39;SYNCED&#39; AND github_issue_number IS NOT NULL AND github_issue_url IS NOT NULL)
      OR (issue_sync &lt;&gt; &#39;SYNCED&#39; AND github_issue_number IS NULL AND github_issue_url IS NULL)
  );</code></pre>
<p>URL을 번호로 조립하지 않고 그대로 저장한 것도 의도다. 저장소가 옮겨지거나 호스트가 바뀌어도 과거 티켓의 링크가 유효해야 한다.</p>
<h2 id="사용자-입력을-이슈-본문에-넣을-때-1">사용자 입력을 이슈 본문에 넣을 때</h2>
<p>이건 만들고 나서 알아챈 함정이다. GitHub은 이슈 본문의 <code>@이름</code>을 멘션으로, <code>#123</code>을 이슈 참조로 확장한다. 사용자가 신고 내용에 이렇게 쓰면?</p>
<blockquote>
<p>@channel 님 확인 부탁드립니다 #123 관련입니다</p>
</blockquote>
</li>
<li><p><em>엉뚱한 사람에게 알림이 가고, 관계없는 이슈에 역참조가 붙는다.*</em> 사용자는 그냥 텍스트로 쓴 것인데.</p>
<p>코드 블록 안에서는 확장되지 않는다. 게다가 붙여넣은 로그의 공백·들여쓰기도 그대로 보존되니 버그 리포트 본문에는 오히려 적합하다. 그래서 사용자 입력을 통째로 코드 블록에 넣었다.</p>
<p>문제는 사용자가 <strong>마크다운 코드블록째로 붙여넣는 경우</strong>다. 울타리가 같으면 블록이 중간에 닫힌다. 내용 안의 가장 긴 백틱 연속보다 하나 더 긴 울타리를 계산해서 쓴다.</p>
<pre><code class="language-java">private static String fenced(String content) {
   int longestRun = 0;
   int run = 0;
   for (char c : content.toCharArray()) {
       run = c == &#39;`&#39; ? run + 1 : 0;
       longestRun = Math.max(longestRun, run);
   }
   String fence = &quot;`&quot;.repeat(Math.max(3, longestRun + 1));
   return fence + &quot;text\n&quot; + content + &quot;\n&quot; + fence;
}</code></pre>
<p>제목에도 함정이 하나 있었다. 접수 번호를 <code>#12</code> 로 쓰려다 말았다 — 본문이라면 GitHub이 12번 이슈 링크로 바꿔버려 엉뚱한 곳을 가리킨다. 제목에선 확장되지 않지만 사람이 그렇게 읽으니 <code>[헬프데스크 12]</code> 로 통일했다.</p>
<h2 id="out-포트를-repository와-gateway로-나눴다-1">out 포트를 repository와 gateway로 나눴다</h2>
<p>클린 아키텍처로 짜면서 out 포트를 다 <code>domain/repository</code> 에 두고 있었는데, 이슈 등록을 거기 넣으려니 어색했다.</p>
</li>
<li><p><strong>repository</strong>: 우리 데이터를 우리가 보관하는 곳. 실패는 예외 상황이다</p>
</li>
<li><p><strong>gateway</strong>: 남의 시스템에 요청을 보내는 것. <strong>실패가 정상 경로에 포함되고</strong>, 타임아웃이 필요하고, 재시도 의미가 다르다</p>
<p>성격이 다르면 다루는 방법도 다르다. 같은 패키지에 두면 그 차이가 흐려져서 <code>domain/gateway</code> 로 나눴다. 의존 방향은 동일하게 안쪽으로만.</p>
<pre><code>adapter/in/web → application/usecase → domain/{model, repository, gateway}
                                            ↑
                        infrastructure/{persistence, github} 가 구현</code></pre><p>타임아웃은 연결 3초 / 응답 5초로 짧게 잡았다. 접수 요청 안에서 동기로 호출하므로, GHE가 느려질 때 사용자의 신고 등록이 같이 늦어지면 안 된다.</p>
<h2 id="테스트에서-배운-것-두-가지-1">테스트에서 배운 것 두 가지</h2>
<h3 id="1-어댑터는-목킹하면-정작-틀리는-부분을-건너뛴다-1">1. 어댑터는 목킹하면 정작 틀리는 부분을 건너뛴다</h3>
<p>HTTP 클라이언트를 목킹해서 &quot;<code>create()</code> 를 호출했는가&quot;를 검증하는 테스트를 쓸 뻔했다. 그런데 이 클래스의 버그는 그런 데서 안 나온다.</p>
</li>
<li><p>URL 경로를 틀렸다</p>
</li>
<li><p>응답 필드가 스네이크케이스(<code>html_url</code>)라 매핑이 안 붙는다</p>
</li>
<li><p>본문에 멘션이 살아 있다</p>
<p>전부 <strong>직렬화·경로 층</strong>의 문제고, 클라이언트를 목킹하면 그 층을 통째로 건너뛴다. 그래서 루프백에 작은 HTTP 서버를 띄우고 보낸 요청을 그대로 붙잡았다. 의존성 추가도 필요 없다.</p>
<pre><code class="language-java">server = HttpServer.create(new InetSocketAddress(&quot;127.0.0.1&quot;, 0), 0);
server.createContext(&quot;/&quot;, exchange -&gt; {
  received.add(new RecordedRequest(
      exchange.getRequestMethod(),
      exchange.getRequestURI().getPath(),
      new String(exchange.getRequestBody().readAllBytes(), UTF_8)));
  // 준비된 응답 반환
});</code></pre>
<p><code>html_url</code> 매핑 누락은 실제로 이 테스트에서 걸렸다.</p>
<h3 id="2-rls가-check-제약을-가려서-무력한-테스트가-통과했다-1">2. RLS가 CHECK 제약을 가려서, 무력한 테스트가 통과했다</h3>
<p>이게 이번에 가장 배운 점이다.</p>
<p>이 프로젝트는 테넌트 격리를 Postgres RLS로 강제한다. 세션 변수 <code>app.tenant_id</code> 가 트랜잭션마다 세팅되고, 정책이 그 값으로 행을 필터링한다.</p>
<p>위에서 만든 CHECK 제약이 실제로 걸리는지 확인하려고, 일부러 잘못된 UPDATE를 던지는 테스트를 썼다.</p>
<pre><code class="language-java">JdbcTemplate jdbc = new JdbcTemplate(dataSource);
assertThatThrownBy(() -&gt; jdbc.update(
  &quot;UPDATE help_desk_ticket SET issue_sync = &#39;SYNCED&#39; WHERE id = ?&quot;, id))
  .isInstanceOf(DataAccessException.class);</code></pre>
</li>
<li><p><em>실패했다.*</em> 예외가 안 났다.</p>
<p>왜? 이 <code>JdbcTemplate</code> 은 테넌트 세션 변수를 세팅하지 않는다. 그러니 RLS <code>USING</code> 절이 먼저 그 행을 안 보이게 만들고, <strong>0건 갱신 → 정상 종료</strong>. CHECK 제약까지 아예 도달하지 않는다.</p>
<p>무서운 건 이거다. <strong>테스트가 실패해서 알았지만, 반대 방향이었으면 몰랐다.</strong> 만약 내가 <code>assertThatThrownBy</code> 가 아니라 다른 형태로 썼다면, 혹은 제약이 애초에 없었다면 — 이 테스트는 <strong>제약이 존재하지 않아도 똑같이 통과</strong>한다. 아무것도 검증하지 않는 테스트가 초록불로
남는다.</p>
<p>제약 자체를 보려면 RLS를 우회해야 한다. Testcontainers의 기본 유저는 superuser라 RLS를 무시하니 그걸로 찌른다.</p>
<pre><code class="language-java">/** RLS 를 우회해(superuser) SQL 을 그대로 실행한다 — 제약 자체를 검증할 때만 쓴다. */
private static void asSuperuser(String sql) throws SQLException {
   try (Connection con = DriverManager.getConnection(
           postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
        Statement st = con.createStatement()) {
       st.executeUpdate(sql);
   }
}</code></pre>
<pre><code class="language-java">assertThatThrownBy(() -&gt; asSuperuser(
   &quot;UPDATE help_desk_ticket SET issue_sync = &#39;SYNCED&#39; WHERE id = &quot; + id))
   .isInstanceOf(SQLException.class)
   .hasMessageContaining(&quot;help_desk_ticket_issue_link_chk&quot;);</code></pre>
<p>제약 이름까지 검증한다. 다른 이유로 터진 예외를 성공으로 세지 않기 위해서.</p>
<p>일반화하면 이렇다. <strong>보안 계층(RLS)과 정합성 계층(CHECK)은 검증 순서가 있고, 앞의 계층이 뒤의 계층을 가릴 수 있다.</strong> 뒤의 것을 테스트하려면 앞의 것을 의도적으로 비켜가야 한다. 반대로 RLS를 테스트할 때 superuser를 쓰면 격리가 안 걸려도 통과한다 — 이 프로젝트가 이미 알고
있던 함정인데, 같은 구조가 한 겹 더 있었던 셈이다.</p>
<h2 id="잔가지-두-개">잔가지 두 개</h2>
</li>
<li><p><em>Spring Boot 4.1은 Jackson 3을 쓴다.*</em> 테스트에서 <code>com.fasterxml.jackson.databind.ObjectMapper</code> 를 import했더니 패키지가 없다고 했다. databind가 <code>tools.jackson.databind</code> 로 옮겨졌다. 애노테이션(<code>@JsonProperty</code>)은 여전히 <code>com.fasterxml.jackson.annotation</code> 에 남아
있어서, 프로덕션 코드는 멀쩡히 컴파일되고 테스트만 깨졌다. 헷갈리기 좋은 조합.</p>
</li>
<li><p><em>마이그레이션 번호 충돌.*</em> 다른 브랜치(리뷰 중인 PR)가 <code>V2</code> 를 이미 쓰고 있었다. 같은 번호를 쓰면 먼저 머지된 쪽만 적용되고 나중 쪽은 checksum 충돌로 부팅이 실패한다. <code>V3</code> 로 잡았다. 브랜치 여러 개가 동시에 도는 팀이라면 마이그레이션 파일 번호를 브랜치 파기 전에
확인하는 게 낫다.</p>
<h2 id="정리">정리</h2>
<p>핵심은 하나였다. <strong>외부 시스템 연동은 실패가 정상 경로에 포함된다.</strong> 그걸 인정하고 설계하면 나머지가 따라온다.</p>
</li>
<li><p>실패로 사용자 입력을 버리지 않기 위해 → 트랜잭션을 걸지 않는다</p>
</li>
<li><p>&quot;실패&quot;와 &quot;안 함&quot;을 구분하기 위해 → 상태를 3개로 둔다</p>
</li>
<li><p>모순된 상태가 조용히 렌더링되지 않게 → 값 객체 + DB CHECK</p>
</li>
<li><p>성격이 다른 포트를 → repository / gateway 로 나눈다</p>
<p>그리고 테스트는 <strong>틀릴 수 있는 층을 실제로 지나가는지</strong> 봐야 한다. 목킹은 그 층을 건너뛰고, 다른 계층의 방어가 검증하려던 계층을 가릴 수도 있다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[측정 가능한 코드 검색 엔진 만들기 (2) — 정답셋을 자동으로 만들다가 만난 함정 3개]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-%EC%A0%95%EB%8B%B5%EC%85%8B%EC%9D%84-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EB%8B%A4%EA%B0%80-%EB%A7%8C%EB%82%9C-%ED%95%A8%EC%A0%95-3%EA%B0%9C</link>
            <guid>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-%EC%A0%95%EB%8B%B5%EC%85%8B%EC%9D%84-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EB%8B%A4%EA%B0%80-%EB%A7%8C%EB%82%9C-%ED%95%A8%EC%A0%95-3%EA%B0%9C</guid>
            <pubDate>Thu, 30 Jul 2026 14:34:25 GMT</pubDate>
            <description><![CDATA[<h2 id="지난-이야기">지난 이야기</h2>
<p><a href="%EB%A7%81%ED%81%AC">1편</a>에서 코드보다 문서를 먼저 쓰고, 일부러 조악한 baseline 검색기를 만들었다. 그리고 이런 결론에 도달했다.</p>
<blockquote>
<p>내 프로젝트(FolioSage)로는 &quot;느낌&quot;까지밖에 못 본다. Hit@10 같은 진짜 숫자를 내려면 <strong>정답셋이 있는 레포</strong>가 필요하다.</p>
</blockquote>
<p>정답셋이란 <code>(질의, 정답 파일)</code> 쌍이다. &quot;이 질문을 검색하면 이 파일이 나와야 한다&quot;는 채점 기준. 이게 있어야 &quot;검색이 잘 된다&quot;를 숫자로 증명할 수 있다.</p>
<p>문제는 이걸 손으로 만들면 수백 개를 일일이 적어야 한다는 것이다. 그래서 GitHub의 구조를 이용하기로 했다.</p>
<pre><code>질의 = 이슈 내용 (&quot;protobuf만 쓰는데 빌드가 깨진다&quot;)
정답 = 그 이슈를 고친 커밋에서 바뀐 파일들</code></pre><p>이슈와 그걸 닫은 커밋은 이미 연결돼 있으니, <strong>정답셋을 자동으로 긁을 수 있다.</strong> 이론상으로는.</p>
<p>이 글은 그 &quot;이론상&quot;이 현실과 부딪히며 함정 3개를 만난 기록이다. 대상 레포는 <code>spring-projects/spring-boot</code>로 정했다. 내가 매일 쓰는 프레임워크라 정답이 말이 되는지 눈으로 검수할 수 있기 때문이다.</p>
<hr>
<h2 id="시작-github-api로-이슈-긁기">시작: GitHub API로 이슈 긁기</h2>
<p>닫힌 이슈를 5개만 긁어봤다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/04db719f-3be7-46e0-84ac-37b6f99ff1a7/image.png" alt=""></p>
<p>바로 이상한 걸 발견했다. 최신 닫힌 이슈들이 죄다 이런 식이었다.</p>
<pre><code>#51163 &quot;Update the build for release train participation&quot;  body 0자
#51162 &quot;Use JacksonJsonSerde in Kafka Streams documentation&quot;  type: documentation</code></pre><p>버그가 아니라 <strong>잡무와 문서 작업</strong>이다. 우리가 원하는 건 &quot;증상이 서술된 버그 → 그걸 고친 코드&quot;인데 이건 그 패턴이 아니다. <code>type: bug</code> 라벨로 필터를 걸었다.</p>
<hr>
<h2 id="함정-1-forward-port-껍데기">함정 1: forward-port 껍데기</h2>
<p>버그 라벨을 걸었더니, 이번엔 전부 이렇게 나왔다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/26b69c2c-bccb-4677-a9ea-0e45ac68f256/image.png" alt=""></p>
<pre><code>#51136 &quot;Micrometer registries pin the application context&quot;
       body: &quot;Forward port of issue #51135 to 4.2.x.&quot;  (38자)
#51135 &quot;Micrometer registries pin the application context&quot;
       body: &quot;Forward port of issue #50886 to 4.1.x.&quot;  (38자)</code></pre><p>spring-boot는 여러 버전(4.1.x, 4.2.x)을 동시에 유지보수한다. 버그 하나를 고치면 그걸 다른 버전 브랜치에도 반영(forward port)하는데, <strong>그때마다 &quot;포팅했다&quot;는 껍데기 이슈가 새로 생긴다.</strong> 실제 내용은 원본 이슈에 있고, 이건 기록일 뿐이다.</p>
<p>두 가지가 문제였다. 본문이 38자라 질의로 못 쓰고, 같은 버그가 버전별로 중복된다. <code>status: forward-port</code> 라벨을 제외했다.</p>
<hr>
<h2 id="함정-2-이슈-템플릿-오염">함정 2: 이슈 템플릿 오염</h2>
<p>이제 진짜 버그 이슈가 나오기 시작했다. 그런데 본문 미리보기가 이상했다.</p>
<pre><code>&lt;!-- Thanks for raising a Spring Boot issue. Please take the time to
review the following categories... 🙅 &quot;Please DO NOT Raise an Issue&quot; ... --&gt;</code></pre><p>이건 버그 내용이 아니라 <strong>GitHub 이슈 템플릿의 안내 주석</strong>이다. 이슈를 쓸 때 깔리는 가이드 텍스트. 여러 이슈가 전부 이 똑같은 문구로 시작했다.</p>
<p>이게 왜 치명적이냐면 — 이 텍스트를 그대로 임베딩하면, 모든 이슈가 똑같은 &quot;Thanks for raising...&quot; 로 시작하니까 <strong>서로 다른 버그가 다 비슷하게 보인다.</strong> 임베딩이 공통 문구에 끌려가서 질의 신호가 죽는다.</p>
<p>HTML 주석을 제거하는 전처리를 넣었다.</p>
<pre><code class="language-python">def clean_body(body: str) -&gt; str:
    if not body:
        return &quot;&quot;
    cleaned = re.sub(r&quot;&lt;!--.*?--&gt;&quot;, &quot;&quot;, body, flags=re.DOTALL)
    return cleaned.strip()</code></pre>
<p>효과는 극적이었다. 한 이슈는 본문 2113자 중 <strong>1775자가 템플릿</strong>이었다. 정제하니 338자로 줄었고, 미리보기가 드디어 실제 버그 내용(&quot;I noticed that spring-boot-h2-console pulls...&quot;)으로 시작했다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/155846e7-e1e7-4628-bf94-8a8cd2394265/image.png" alt=""></p>
<p>여기서 배운 건, <strong>정제 후 길이로 필터링해야 한다</strong>는 것이다. 정제 전 2113자로 필터를 통과시키면, 알맹이는 338자뿐인 이슈가 섞여 들어온다.</p>
<hr>
<h2 id="함정-3-마지막-커밋-함정-제일-크게-당했다">함정 3: &quot;마지막 커밋&quot; 함정 (제일 크게 당했다)</h2>
<p>이제 질의(이슈 본문)는 확보했다. 남은 건 정답 — &quot;그래서 이 버그를 고친 파일이 뭐냐&quot;다.</p>
<p>이슈 자체엔 그 정보가 없다. 그걸 고친 커밋에 있다. GitHub의 timeline API로 &quot;이 이슈를 닫은 게 뭐냐&quot;를 추적했다. 이슈 #50822로 해봤더니:</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/b29003d2-094d-49a8-be1e-a7040449c673/image.png" alt=""></p>
<pre><code>PR #50824  &quot;Only configure protobuf gRPC generation...&quot;
PR #50827  &quot;Fix gRPC auto-configuration when protobuf...&quot;
commit 7483278...
commit b140c8d...</code></pre><p>연결된 게 넷이다. 그리고 이 이슈는 한 번 닫혔다가 reopen됐다가 다시 닫혔다. 실제 개발이 그렇다 — 처음 고친 게 불완전해서 다시 열고 제대로 고친 것이다.</p>
<p>여기서 나는 &quot;마지막에 닫은 커밋이 최종 수정이겠지&quot;라고 가정했다. 그럴듯하지 않은가? 마지막 커밋(b140c8d)의 변경 파일을 봤다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/946b2cd4-f547-465a-abda-27ff84b3884e/image.png" alt=""></p>
<pre><code>message: Align docs with changes to Gradle gRPC configuration
changed files: 1
  grpc.adoc  [NON-JAVA]</code></pre><p><strong>문서 파일 하나.</strong> 자바 코드가 없다. 마지막 커밋은 &quot;고친 내용에 맞춰 문서를 정리&quot;한 것이었고, 진짜 버그 수정은 앞 커밋에 있었다. 앞 커밋(7483278)을 보니:</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/273ccf32-13c0-4f1d-a3b4-45d3a0cc02ea/image.png" alt=""></p>
<pre><code>message: Only apply grpc defaults when grpc plugin is configured
  ProtobufPluginAction.java              ← 진짜 fix
  reacting.adoc                          [NON-JAVA]
  ...IntegrationTests.gradle             [TEST]</code></pre><p>여기 있었다. <code>ProtobufPluginAction.java</code>. 만약 &quot;마지막 커밋&quot; 규칙을 그대로 썼다면 이 이슈의 정답은 <strong>문서 파일</strong>이 됐을 것이고, 검색 엔진을 그걸로 채점했을 것이다. 완전히 틀린 정답으로.</p>
<p><strong>데이터를 직접 추적하지 않았으면 절대 발견 못 했을 함정이다.</strong> &quot;마지막 커밋이 최종 fix&quot;라는 그럴듯한 가정을 실물이 반박했다.</p>
<p>규칙을 고쳤다.</p>
<pre><code>- 마지막 커밋만 보지 않는다
- 이슈를 닫은 모든 커밋의 변경 파일을 합집합으로 모은다
- 테스트/문서/빌드스크립트를 걸러내고
- 남은 프로덕션 .java 만 정답으로</code></pre><hr>
<h2 id="첫-정답-쌍-완성">첫 정답 쌍 완성</h2>
<p>이 규칙을 적용하니 이슈 #50822의 정답이 깔끔하게 하나로 수렴했다.</p>
<pre><code>질의 = &quot;protobuf만 쓰고 grpc 없을 때 Gradle 빌드 실패...&quot; (이슈 본문)
정답 = ProtobufPluginAction.java</code></pre><p>이게 채점의 기준이다. 검색 엔진한테 저 이슈 본문을 던졌을 때 <code>ProtobufPluginAction.java</code>를 상위에 올리면 맞은 것, 못 올리면 틀린 것.</p>
<hr>
<h2 id="오늘의-교훈">오늘의 교훈</h2>
<p>세 함정에 공통점이 있다. <strong>셋 다 데이터를 직접 눈으로 보기 전엔 몰랐다.</strong></p>
<ul>
<li>forward-port 껍데기: 라벨만 믿었으면 통과했을 것</li>
<li>템플릿 오염: 본문을 안 봤으면 그대로 임베딩했을 것</li>
<li>마지막 커밋 함정: 그럴듯한 가정이 완전히 틀렸던 것</li>
</ul>
<p>&quot;이슈 긁어서 커밋 파일을 정답으로&quot; — 한 줄로 요약되는 이 아이디어가, 실제 데이터에선 예외투성이였다. 그리고 그 예외 하나하나가 정답셋의 품질을 좌우한다. 정답셋이 오염되면 이후 모든 Hit@10 숫자가 거짓말이 되기 때문이다.</p>
<p>그래서 오늘 발견한 필터 규칙들을 코드보다 먼저 문서로 정리했다. 내일 이걸 자동화 스크립트로 옮겨서, 이슈 수백 개에 대해 이 전 과정을 돌릴 예정이다. 드디어 정답셋이 쌓이기 시작한다.</p>
<hr>
<p><em>다음 편: 정답셋 자동 수집 스크립트와, 첫 Hit@10 측정. &quot;느낌&quot;이 드디어 &quot;숫자&quot;가 됩니다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[측정 가능한 코드 검색 엔진 만들기 (1) — 코드보다 문서를 먼저 썼다]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-%EC%BD%94%EB%93%9C%EB%B3%B4%EB%8B%A4-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EB%A8%BC%EC%A0%80-%EC%8D%BC%EB%8B%A4</link>
            <guid>https://velog.io/@gitit-sehyeon/%EC%B8%A1%EC%A0%95-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-%EC%BD%94%EB%93%9C%EB%B3%B4%EB%8B%A4-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EB%A8%BC%EC%A0%80-%EC%8D%BC%EB%8B%A4</guid>
            <pubDate>Wed, 29 Jul 2026 13:59:08 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>RAG 프로젝트를 하나 시작했다. 그런데 흔한 &quot;문서 넣고 챗봇 만들기&quot;는 하고 싶지 않았다. 그런 파이프라인(청킹 → 임베딩 → 유사도 검색 → LLM)은 이제 하루면 만든다. 면접에서 &quot;RAG 해봤습니다&quot; 하면 바로 돌아오는 질문이 있다.</p>
<blockquote>
<p>&quot;그래서 검색 품질은 어떻게 측정하셨어요?&quot;</p>
</blockquote>
<p>대부분 여기서 막힌다. 그래서 이번 프로젝트의 정체성을 처음부터 <strong>&quot;챗봇&quot;이 아니라 &quot;측정 가능하고 개선을 수치로 증명하는 검색 시스템&quot;</strong>으로 잡았다.</p>
<p>프로젝트 이름은 <strong>ariadne</strong>. 그리스 신화에서 테세우스가 미궁을 빠져나오도록 실타래를 건넨 아리아드네에서 따왔다. 거대한 낯선 코드베이스가 미궁이고, 이 도구가 따라갈 실이다.</p>
<p>이 글은 그 첫날 기록이다. 특이한 점이 하나 있다면, <strong>코드보다 문서를 먼저 썼다는 것</strong>이다.</p>
<hr>
<h2 id="왜-코드베이스-검색인가">왜 코드베이스 검색인가</h2>
<p>RAG 평가에서 가장 귀찮은 일은 정답셋을 만드는 것이다. &quot;이 질문의 정답 문서는 이것&quot;을 사람이 수백 개 손으로 적어야 한다. 주관적이고, 오래 걸리고, 신뢰도도 애매하다.</p>
<p>그런데 GitHub에는 이미 짝지어진 데이터가 있다.</p>
<pre><code>질문 = 이슈 내용 (&quot;결제하면 중복 청구됨&quot;)
정답 = 그 이슈를 고친 PR에서 실제로 수정한 파일들</code></pre><p>이슈와 그걸 닫은 커밋은 이미 연결돼 있고, 커밋에는 어떤 파일을 고쳤는지 기록돼 있다. 즉 <strong>정답셋이 공짜로 수천 개 생긴다.</strong> 라벨링 비용이 0이다.</p>
<p>게다가 코드 검색에는 진짜 어려운 문제가 있다. 코드는 임베딩이 잘 안 먹는다. <code>getUserById</code> 같은 식별자는 의미 검색보다 키워드 검색이 훨씬 강하고, &quot;재시도하면 중복됨&quot; 같은 증상 서술은 반대로 의미 검색이 필요하다. 그래서 <strong>하이브리드 검색의 효과가 극적으로 크게 나타난다.</strong> 이걸 수치로 증명하는 게 이 프로젝트의 목표 중 하나다.</p>
<hr>
<h2 id="코드보다-문서를-먼저-쓴-이유">코드보다 문서를 먼저 쓴 이유</h2>
<p>이 프로젝트의 제1원칙을 정했다.</p>
<blockquote>
<p><strong>측정할 수 없는 개선은 하지 않는다.</strong></p>
</blockquote>
<p>이 원칙을 지키려면 &quot;잘 찾는다&quot;가 뭔지를 코드 짜기 전에 정의해야 한다. 사후에 지표를 고르면, 잘 나온 지표를 고르게 되기 때문이다. 그건 측정이 아니라 사후 정당화다.</p>
<p>그래서 첫 커밋을 코드가 아니라 <strong>지표 명세 문서</strong>로 했다. 여기서 정한 것들:</p>
<p><strong>무엇을 평가하지 않을지 먼저 정했다.</strong> 이 시스템의 출력은 &quot;답변 문장&quot;이 아니라 &quot;관련 파일 목록&quot;이다. 답변 생성은 상위 도구(Claude Code 등)가 한다. 그래서 생성 품질(faithfulness 같은 LLM 심판 지표)은 평가하지 않는다. 검색 결과를 정답 파일 집합과 비교해 <strong>기계적으로 채점</strong>하므로, 수치가 심판 모델에 휘둘리지 않는다.</p>
<p><strong>주 지표를 하나로 못박았다.</strong> Hit@10(정답 파일이 상위 10개에 하나라도 있는가). 여러 지표를 동시에 최적화하면 결정을 못 내린다. 나머지(Recall, MRR, 지연시간)는 가드레일이다.</p>
<p><strong>SLO를 미리 적었다.</strong> 질의 p95 &lt; 800ms, 재색인 &lt; 5분. 만들기 전에 목표치를 박아두는 것과, 나중에 &quot;빠릅니다&quot;라고 쓰는 것은 완전히 다른 문서가 된다.</p>
<p>그리고 주요 설계 결정마다 <strong>ADR(Architecture Decision Record)</strong>을 남겼다. 예를 들어:</p>
<ul>
<li>주력이 Java인데 왜 Python으로 만드는가 (생태계 정합성)</li>
<li>왜 진짜 BM25 대신 PostgreSQL FTS로 시작하는가 (v1의 의도적 단순화)</li>
<li>왜 API 임베딩 대신 로컬 임베딩인가 (실험 반복 비용과 재현성)</li>
</ul>
<p>ADR의 핵심은 &quot;이렇게 정했다&quot;가 아니라 <strong>&quot;이런 조건이 되면 이 결정을 뒤집는다&quot;</strong>까지 적는 것이다. 그러면 결정이 독단이 아니라 검증 가능한 가설이 된다.</p>
<hr>
<h2 id="phase-0-일부러-조악한-baseline-만들기">Phase 0: 일부러 조악한 baseline 만들기</h2>
<p>문서가 서고 나서 코드로 넘어갔다. 첫 목표는 <strong>&quot;가장 멍청한 검색기&quot;</strong>다.</p>
<p>여기서 잘 만들면 안 된다. 이게 baseline이고, 이후 모든 개선은 이 낮은 출발점 대비로 측정되기 때문이다. 처음부터 잘 만들면 나중에 &quot;무엇을 개선했는지&quot;를 보여줄 수가 없다.</p>
<p>그래서 의도적으로:</p>
<ul>
<li>청킹: AST 없이 그냥 40줄씩 자름</li>
<li>검색: 벡터 유사도만 (BM25 없음, 리랭커 없음)</li>
</ul>
<h3 id="기술-스택">기술 스택</h3>
<table>
<thead>
<tr>
<th>영역</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>언어</td>
<td>Python 3.12</td>
</tr>
<tr>
<td>임베딩</td>
<td>BGE-M3 (로컬 실행)</td>
</tr>
<tr>
<td>저장소</td>
<td>PostgreSQL + pgvector</td>
</tr>
<tr>
<td>파싱</td>
<td>tree-sitter (아직 미사용)</td>
</tr>
</tbody></table>
<p>pgvector는 Docker로 바로 띄웠다. <code>pgvector/pgvector:pg17</code> 이미지에 확장이 이미 들어있어서 <code>CREATE EXTENSION</code>만 하면 됐다.</p>
<h3 id="스키마">스키마</h3>
<p>청크 하나가 테이블 한 행이다.</p>
<pre><code class="language-sql">CREATE TABLE chunks (
    id          BIGSERIAL PRIMARY KEY,
    repo        TEXT   NOT NULL,
    file_path   TEXT   NOT NULL,     -- 정답 판정의 단위
    start_line  INT    NOT NULL,
    end_line    INT    NOT NULL,
    content     TEXT   NOT NULL,
    embedding   vector(1024)         -- BGE-M3 차원수
);

CREATE INDEX chunks_embedding_idx
    ON chunks USING hnsw (embedding vector_cosine_ops);</code></pre>
<p><code>file_path</code>가 핵심 컬럼이다. 검색은 청크(코드 조각) 단위로 하지만, 정답 판정은 파일 단위이기 때문이다.</p>
<h3 id="검색-쿼리">검색 쿼리</h3>
<p>pgvector의 코사인 거리 연산자 <code>&lt;=&gt;</code>를 쓴다.</p>
<pre><code class="language-sql">SELECT file_path, start_line, end_line,
       1 - (embedding &lt;=&gt; %s::vector) AS score
FROM chunks
ORDER BY embedding &lt;=&gt; %s::vector
LIMIT %s</code></pre>
<p>거리라서 작을수록 가깝고, <code>1 - 거리</code>로 뒤집어 &quot;높을수록 유사한 점수&quot;로 보여준다.</p>
<hr>
<h2 id="첫-검색-결과">첫 검색 결과</h2>
<p>내 프로젝트 중 하나인 <strong>FolioSage</strong>(Spring Boot 백엔드)를 색인해서 돌려봤다. 71개 파일 → 168개 청크.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/069f2457-a91f-46e4-a9c3-05eb238fc669/image.png" alt=""></p>
<p>&quot;JWT 토큰 검증&quot;으로 검색하니 1위가 <code>JwtTokenProvider.java</code>(점수 0.612). 정확했다. 2, 3위도 전부 인증 관련 파일이었다.</p>
<p>조악한 baseline인데도 되긴 된다. 그런데 여기서부터가 진짜 재밌었다.</p>
<hr>
<h2 id="baseline의-성격을-파헤치기">baseline의 성격을 파헤치기</h2>
<p>그냥 &quot;잘 되네&quot;로 넘어가면 실험이 아니다. 있는 기능 / 표현을 바꾼 것 / 영어 질의 / 없는 기능을 섞어서 10개 질의를 던지고, baseline이 어떻게 반응하는지 관찰했다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/1e752b1c-775c-40ae-9864-1364c5b55ce6/image.png" alt=""><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/6fd1ec76-56b7-4d09-85ea-d2dc0dd58587/image.png" alt=""><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/f3caa813-8669-4f55-9e06-969474deab0f/image.png" alt=""><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/7bc20e41-845d-4166-9f1c-4285442dd7df/image.png" alt=""></p>
<h3 id="발견-1--점수로-정답-없음을-판별할-수-있지만-경계가-겹친다">발견 1 — 점수로 &quot;정답 없음&quot;을 판별할 수 있지만, 경계가 겹친다</h3>
<p>질의별 1위 점수를 정렬해보니:</p>
<pre><code>포트폴리오 스토리 생성    0.699   (있음)
사용자 인증 처리         0.603   (있음)
...
파일 자동 정리           0.559   (있음, 최저)
─────────────────────────────
결제 환불 처리           0.486   (없음)</code></pre><p>명백히 없는 기능(&quot;결제 환불&quot;)은 점수가 확 낮게 깔렸다. <strong>점수로 관련성 유무를 어느 정도 판별할 수 있다는 뜻</strong>이다. 다만 있는 기능의 최저점(0.559)과 경계가 가까워서, 단일 임계값으로 자르는 건 위험하다. 이건 나중에 하이브리드/리랭커가 이 간극을 벌려주는지 확인할 지점이다.</p>
<h3 id="발견-2--내-대조군-설계가-틀렸다-그런데-그게-좋았다">발견 2 — 내 대조군 설계가 틀렸다 (그런데 그게 좋았다)</h3>
<p>&quot;실시간 채팅&quot;을 &quot;없는 기능&quot; 대조군으로 넣었는데, 1위 점수가 0.550으로 애매하게 높게 나왔다. 왜지 하고 봤더니 — FolioSage에 진짜 <code>ChatRequest</code>, <code>ChatResponse</code>(AI 방어 대화 기능)가 있었다.</p>
<p><strong>틀린 건 검색기가 아니라 내 실험 설계였다.</strong> 검색기는 있는 걸 정직하게 찾아준 것이다. 오히려 baseline이 제대로 동작한다는 증거가 됐다.</p>
<h3 id="발견-3--한글-질의도-꽤-잘-된다">발견 3 — 한글 질의도 꽤 잘 된다</h3>
<p>같은 대상을 한글/영어로 물어봤다.</p>
<pre><code>파일 정리:  &quot;파일 자동 정리&quot; 0.559  vs  &quot;file organization&quot; 0.592  (Δ +0.033)
파일 인증:  &quot;SHA-256 파일 인증&quot; 0.574 vs &quot;certificate hash&quot; 0.594  (Δ +0.020)</code></pre><p>코드가 영어라 영어 질의가 조금 유리했지만, 격차가 작았다. BGE-M3가 다국어 모델이라 한글 질의도 실용적이라는 뜻. 한국어로 질문해도 쓸 만하다.</p>
<h3 id="발견-4--의미-검색이-어휘-불일치를-넘는다">발견 4 — 의미 검색이 어휘 불일치를 넘는다</h3>
<p>&quot;AI로 파일 분류하기&quot;로 검색하니 <code>SmartOrganizerService</code>가 1위였다. 코드에는 &quot;분류&quot;라는 단어가 없다(organize/classify로 돼 있다). <strong>어휘가 하나도 안 겹쳐도 의미로 찾은 것.</strong> 이게 dense 검색의 강점이고, 반대로 키워드 검색만으로는 놓쳤을 질의다. 하이브리드가 필요한 이유다.</p>
<h3 id="발견-5--청크-절단-문제가-눈에-보인다">발견 5 — 청크 절단 문제가 눈에 보인다</h3>
<p>거의 모든 결과에서 상위권이 같은 파일의 다른 조각으로 도배됐다. &quot;파일 자동 정리&quot;는 1·2·3위가 전부 <code>SmartOrganizerService</code>의 서로 다른 40줄 조각(41-80, 81-120, 201-224)이었다.</p>
<p>40줄씩 기계적으로 잘랐더니 하나의 클래스가 여러 조각으로 흩어진 것이다. top-10이라지만 실제로는 소수 파일만 대표하는 셈. 이건 두 가지 개선의 직접적인 동기가 됐다.</p>
<ul>
<li><strong>AST 기반 청킹</strong>: 클래스/메서드 경계로 자르면 이 흩어짐이 사라진다</li>
<li><strong>파일 단위 롤업</strong>: 같은 파일의 조각들을 하나로 묶어야 한다</li>
</ul>
<hr>
<h2 id="오늘의-결론">오늘의 결론</h2>
<p>baseline을 만들고, 그 baseline의 한계까지 내 눈으로 확인했다. 정리하면:</p>
<ul>
<li>설계 문서(지표 명세, ADR)를 코드보다 먼저 확정</li>
<li>조악한 baseline 검색기 동작 (dense only)</li>
<li>10개 질의로 baseline의 성향과 한계를 정성 분석</li>
</ul>
<p>그리고 이 정성 실험이 다음 할 일을 스스로 가리켰다. FolioSage로는 &quot;느낌&quot;까지밖에 못 본다. Hit@10 같은 진짜 숫자를 내려면 <strong>정답셋이 있는 레포</strong>가 필요하다.</p>
<p>다음 편에서는 Spring Framework의 git 히스토리에서 <code>(이슈, 정답 파일)</code> 쌍을 자동으로 긁어오는 이야기를 쓸 예정이다. 드디어 검색 품질을 수치로 재기 시작한다.</p>
<hr>
<p><em>이 프로젝트는 &quot;측정할 수 없는 개선은 하지 않는다&quot;를 원칙으로, 모든 변경을 지표로 검증하며 진행 중입니다. 다음 편에서 계속됩니다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[헬프데스크 RLS를 alpha에 띄우기까지 — 삽질 기록 (post-mortem)]]></title>
            <link>https://velog.io/@gitit-sehyeon/%ED%97%AC%ED%94%84%EB%8D%B0%EC%8A%A4%ED%81%AC-RLS%EB%A5%BC-alpha%EC%97%90-%EB%9D%84%EC%9A%B0%EA%B8%B0%EA%B9%8C%EC%A7%80-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D-post-mortem</link>
            <guid>https://velog.io/@gitit-sehyeon/%ED%97%AC%ED%94%84%EB%8D%B0%EC%8A%A4%ED%81%AC-RLS%EB%A5%BC-alpha%EC%97%90-%EB%9D%84%EC%9A%B0%EA%B8%B0%EA%B9%8C%EC%A7%80-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D-post-mortem</guid>
            <pubDate>Wed, 29 Jul 2026 09:05:35 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/c8d2807e-1e85-42e1-b0ed-509fdaa2290a/image.png" alt=""></p>
<p>이 문서는 &quot;헬프데스크 + 법인(테넌트) RLS&quot;를 alpha에 배포하면서 <strong>신고 API가 계속 500 나던 문제</strong>를
잡아 나간 과정을, 각 단계의 <em>증상 → 원인 → 왜 위험한가 → 고친 방법</em>으로 처음부터 풀어 설명합니다.
관련 설계 배경은 <a href="adr-0001-rls-tenant-session-variable.md">ADR-0001</a> 참고.</p>
<hr>
<h2 id="0-배경-뭘-하려던-거였나">0. 배경: 뭘 하려던 거였나</h2>
<ul>
<li><strong>목표</strong>: 헬프데스크 기능(문제 신고/목록/해결) + 여러 법인이 한 DB·한 테이블을 공유하되 서로 못 보게
하는 <strong>RLS 테넌트 격리</strong>를, CI/CD로 <strong>alpha 환경에 배포</strong>.</li>
<li><strong>겪은 일</strong>: 배포했더니 신고 API가 계속 <strong>500</strong>. 그런데 원인이 하나가 아니라 <strong>여러 겹</strong>이었고,
하나를 고쳐 배포하면 그다음 원인이 드러나는 식으로 <strong>총 5겹</strong>을 벗겨야 했다.</li>
<li><strong>공통 교훈 먼저</strong>: 여기 나오는 실패는 대부분 <strong>&quot;조용히(silent)&quot; 실패</strong>였다. 500 메시지는 똑같아 보여도
원인이 매번 달랐고, 로그를 끝까지 파야 진짜 원인이 나왔다. 그래서 <strong>로그의 첫 <code>Caused by:</code> 한 줄</strong>이
매번 열쇠였다.</li>
</ul>
<hr>
<h2 id="1막--배포가-뒤섞였다-ci-트리거-문제">1막 — 배포가 뒤섞였다 (CI 트리거 문제)</h2>
<p><strong>증상</strong></p>
<ul>
<li>화면은 뜨는데 동작이 이상. 배포된 이미지 태그의 커밋 sha(<code>a88fa1a</code>)가 <strong>우리 repo 어디에도 없는 유령 커밋</strong>.</li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li>CI가 <code>on: pull_request</code> 였다. → <strong>모든 PR(및 PR 커밋)마다</strong> alpha에 자동 배포.</li>
<li>그래서 서로 다른 사람의 PR이 <strong>같은 alpha를 번갈아 덮어씀</strong>.</li>
<li>게다가 <code>pull_request</code> 이벤트는 GitHub이 <strong>PR을 main에 가상 머지한 임시 커밋</strong>으로 빌드한다 →
그 sha가 <code>a88fa1a</code>. repo에 없어서 &quot;이게 대체 뭐지?&quot; 하고 헤맴.</li>
</ul>
<pre><code class="language-yaml"># before
on:
  pull_request: {}        # ← PR마다 빌드+배포</code></pre>
<p><strong>고친 방법</strong></p>
<pre><code class="language-yaml"># after
on:
  push: { branches: [main] }   # 배포는 main 머지에만
  pull_request: {}             # PR은 &quot;빌드 검증&quot;만 (배포/이미지 push 안 함)</code></pre>
<ul>
<li><code>Bump image tag</code>/<code>trigger-cd</code> 스텝을 <code>if: github.event_name != &#39;pull_request&#39;</code> 로 막음.</li>
<li>이미지 push도 <code>push: ${{ github.event_name != &#39;pull_request&#39; }}</code> 로 PR에선 안 하게.</li>
</ul>
<p><strong>배운 것</strong></p>
<ul>
<li>배포 트리거는 <strong>&quot;실제로 머지된 것&quot;</strong>에만 걸어야 한다. <code>PR = 검증</code>, <code>merge = 배포</code>.</li>
</ul>
<hr>
<h2 id="2막--마이그레이션이-아예-안-돌았다-flyway-autoconfig-모듈-누락">2막 — 마이그레이션이 아예 안 돌았다 (Flyway autoconfig 모듈 누락)</h2>
<p><strong>증상</strong></p>
<pre><code>ERROR: relation &quot;help_desk_ticket&quot; does not exist</code></pre><ul>
<li>그리고 시작 로그에 <strong>Flyway 관련 줄이 한 줄도 없음</strong>(배너/Migrating 전무).</li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li><strong>Spring Boot 4.x는 auto-configuration을 기술별 모듈로 쪼갰다.</strong> Flyway 자동설정이
<code>spring-boot-autoconfigure</code> 에서 <strong><code>spring-boot-flyway</code></strong> 모듈로 이사했다.</li>
<li>우리는 <code>flyway-core</code>(마이그레이션 <strong>엔진</strong>)만 넣고 이 <strong>autoconfig 모듈을 안 넣어서</strong>,
<code>spring.flyway.enabled=true</code> 여도 <strong>부팅 시 마이그레이션이 자동 실행되지 않았다</strong>(그래서 로그도 없음).</li>
</ul>
<p><strong>진단법 (핵심 트릭)</strong></p>
<pre><code class="language-bash"># 배포된 jar 안에 Flyway 라이브러리/모듈이 실제로 들어있나 (unzip 없이)
kubectl exec ... -- sh -c &#39;grep -ac BOOT-INF/lib/spring-boot-flyway /app/app.jar&#39;   # 0 → 모듈 없음</code></pre>
<p><strong>고친 방법</strong></p>
<pre><code class="language-kotlin">// build.gradle.kts
implementation(&quot;org.springframework.boot:spring-boot-flyway&quot;)   // ← 이게 빠져 있었다
implementation(&quot;org.flywaydb:flyway-core&quot;)
implementation(&quot;org.flywaydb:flyway-database-postgresql&quot;)</code></pre>
<p><strong>배운 것</strong></p>
<ul>
<li>Spring Boot 4.x에선 <strong>라이브러리(flyway-core) + Spring 연동 모듈(spring-boot-flyway)</strong> 을
<strong>둘 다</strong> 넣어야 autoconfig가 산다. 라이브러리만 있으면 조용히 아무 일도 안 일어난다.</li>
</ul>
<hr>
<h2 id="3막--스키마를-만들-권한이-없었다-db-권한">3막 — 스키마를 만들 권한이 없었다 (DB 권한)</h2>
<p><strong>증상</strong></p>
<ul>
<li>Flyway가 이제 돌긴 하는데:<pre><code>Migrating schema &quot;axhr_for_leader&quot; to version &quot;1 - create help desk ticket&quot;
Migration ... failed! Changes successfully rolled back.
Message : ERROR: permission denied for database postgres</code></pre></li>
<li>→ 부팅 실패 → 새 pod가 <strong>CrashLoop</strong>, 옛 pod가 계속 트래픽 받아서 &quot;여전히 500&quot;처럼 보임.</li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li>마이그레이션 첫 문장 <code>CREATE SCHEMA IF NOT EXISTS axhr_for_leader</code> 는 <strong>DB 레벨 CREATE 권한</strong>이 필요.</li>
<li>관리형 공유 DB의 <strong>앱 유저에게는 그 권한이 없다</strong>(있으면 아무 스키마나 만들 수 있어 위험).</li>
</ul>
<p><strong>고친 방법</strong> — 관리형 DB 표준 방식으로</p>
<ul>
<li>(앱) 마이그레이션에서 <code>CREATE SCHEMA</code> 제거 + <code>spring.flyway.create-schemas: false</code> (Flyway도 스키마 안 만듦)</li>
<li>(DBA/플랫폼) 스키마를 <strong>미리</strong> 만들고 앱 유저를 소유자로:<pre><code class="language-sql">CREATE SCHEMA axhr_for_leader AUTHORIZATION axhr_for_leader_user;</code></pre>
(플랫폼 컨벤션: <code>&lt;service&gt;</code> 스키마 ↔ <code>&lt;service&gt;_user</code> 소유)</li>
</ul>
<p><strong>배운 것</strong></p>
<ul>
<li>관리형 DB에선 <strong>스키마는 DBA가 만들고, 앱은 &quot;이미 있다고 전제&quot;</strong>. 앱 유저는 스키마 소유자로서
그 안에서만 테이블/RLS를 다룰 권한만 갖는다(최소 권한).</li>
</ul>
<hr>
<h2 id="4막--세션변수가-안-걸렸다-set_config를-매번-건너뜀">4막 — 세션변수가 안 걸렸다 (set_config를 매번 건너뜀)</h2>
<blockquote>
<p>여기부터가 진짜 RLS 배선 버그. ADR-0001의 &quot;데이터 접근 경로에서 set&quot; 방향은 맞았지만 <strong>구현 디테일</strong>에서 삐끗.</p>
</blockquote>
<p><strong>증상</strong></p>
<ul>
<li>테이블은 생겼는데 <strong>신고 등록(INSERT)</strong> 에서:<pre><code>ERROR: new row violates row-level security policy for table &quot;help_desk_ticket&quot;</code></pre></li>
<li>목록(SELECT)은 에러는 없지만 <strong>0건</strong>.</li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li>세션변수 <code>app.tenant_id</code> 를 &quot;트랜잭션당 1회만&quot; 설정하려고, <code>TenantJdbcSupport</code> 가
<strong>DataSource를 중복방지 키</strong>로 썼다:<pre><code class="language-java">DataSource key = jdbcTemplate.getDataSource();
if (TransactionSynchronizationManager.hasResource(key)) return;  // 이미 했으면 skip</code></pre>
</li>
<li>그런데 <strong>Spring은 트랜잭션 커넥션(ConnectionHolder)을 바로 그 DataSource를 키로 이미 바인딩</strong>해둔다.
→ 트랜잭션이 열리는 순간부터 <code>hasResource(dataSource)</code> 가 <strong>항상 true</strong>
→ <strong>set_config를 매번 건너뜀</strong> → <code>app.tenant_id</code> 미설정(NULL)</li>
<li>결과:<ul>
<li>INSERT: <code>WITH CHECK (tenant_id = current_setting(...))</code> → <code>tenant_id = NULL</code> → 위반 → <strong>500</strong></li>
<li>SELECT: <code>USING (...)</code> → NULL → 0건 (조용히 빈 결과)</li>
</ul>
</li>
</ul>
<p><strong>고친 방법</strong></p>
<ul>
<li>과한 &quot;1회 가드&quot;를 <strong>제거</strong>하고 매 접근마다 set_config 실행. <code>SET LOCAL</code>은 같은 값 재설정이 무해(idempotent).<pre><code class="language-java">// after
if (!TransactionSynchronizationManager.isActualTransactionActive())
  throw new IllegalStateException(&quot;@Transactional 경계 안에서만&quot;);  // 가드는 &quot;트랜잭션 여부&quot;만
jdbcTemplate.queryForObject(&quot;SELECT set_config(&#39;app.tenant_id&#39;, ?, true)&quot;, String.class, value);</code></pre>
</li>
</ul>
<p><strong>배운 것</strong></p>
<ul>
<li><code>TransactionSynchronizationManager</code> 의 resource 키로 <strong>DataSource를 쓰면 안 된다</strong> — Spring이 이미
그 키로 커넥션을 관리한다. 굳이 최적화하려다 조용한 버그를 만들었다.</li>
</ul>
<hr>
<h2 id="5막--set_config를-잘못된-방식으로-실행했다-update-vs-query">5막 — set_config를 잘못된 방식으로 실행했다 (update vs query)</h2>
<p><strong>증상</strong></p>
<ul>
<li>4막을 고치니 이번엔:<pre><code>SQL [SELECT set_config(&#39;app.tenant_id&#39;, ?, true)]; A result was returned when none was expected.</code></pre></li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li><code>SELECT set_config(...)</code> 는 <strong>값을 한 행 반환</strong>하는 쿼리다.</li>
<li>그걸 <code>jdbcTemplate.update()</code>(내부적으로 <code>executeUpdate</code>)로 실행하면, PostgreSQL JDBC가
&quot;결과를 반환하는데 update로 불렀다&quot;며 예외를 던진다.</li>
</ul>
<p><strong>고친 방법</strong></p>
<pre><code class="language-java">// before: jdbcTemplate.update(&quot;SELECT set_config(...)&quot;, value)   // ← 결과 반환 SQL을 update로 실행 (X)
// after
jdbcTemplate.queryForObject(&quot;SELECT set_config(&#39;app.tenant_id&#39;, ?, true)&quot;, String.class, value);</code></pre>
<p><strong>배운 것</strong></p>
<ul>
<li><strong>결과를 반환하는 SQL(SELECT/RETURNING 등)</strong> 은 <code>update()</code>가 아니라 <code>query</code>/<code>queryForObject</code> 로 실행한다.</li>
</ul>
<hr>
<h2 id="6막--진짜-근본-원인-검증을-안-했다">6막 — 진짜 근본 원인: 검증을 안 했다</h2>
<p><strong>증상(메타)</strong></p>
<ul>
<li>4·5막의 버그는 왜 <strong>배포해봐야</strong> 드러났나?</li>
</ul>
<p><strong>원인</strong></p>
<ul>
<li>RLS 경로(<code>TenantJdbcSupport</code> → 실제 INSERT/SELECT)를 <strong>진짜 Postgres에 대고 한 번도 실행 안 함</strong>.</li>
<li>그 경로를 타는 <strong>통합테스트(Testcontainers)</strong> 가 있었는데, 초반에 <code>docker info</code> 가 실패해서
&quot;Docker 없음&quot;으로 단정하고 안 돌렸다. <strong>사실 Docker Desktop을 켜니 됐다.</strong></li>
</ul>
<p><strong>고친 방법</strong></p>
<ul>
<li>마지막에 실행 → <strong>테스트 데이터 오염</strong>(테스트끼리 공유 테이블) 발견 → <code>@BeforeEach</code> 에 <code>TRUNCATE</code> +
격리 시맨틱에 맞게 <code>containsExactly</code> → <code>containsOnly</code> 로 교정 → <strong>3케이스 전부 통과</strong>:
상호 격리 / 세션변수 없으면 fail-closed / 다른 테넌트 위조 저장 거부(WITH CHECK).<pre><code class="language-bash">cd backend &amp;&amp; ./gradlew :app:test --tests &quot;*HelpDeskRlsIntegrationTest&quot;   # Docker Desktop만 켜져 있으면 됨</code></pre>
</li>
</ul>
<p><strong>배운 것 (가장 중요)</strong></p>
<ul>
<li><strong>RLS/DB 변경은 배포 전에 위 통합테스트로 로컬 검증</strong>한다. 이걸 처음부터 했으면 <strong>4·5막을 안 겪었다.</strong></li>
<li><code>docker info</code> 가 안 되면 데몬(Docker Desktop)이 꺼져 있는지부터 재확인.</li>
</ul>
<hr>
<h2 id="전체-타임라인-한눈에">전체 타임라인 한눈에</h2>
<table>
<thead>
<tr>
<th>막</th>
<th>증상(로그 키워드)</th>
<th>원인</th>
<th>고침</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>유령 커밋 배포, 브랜치끼리 덮임</td>
<td>CI가 PR마다 배포</td>
<td>배포는 main 머지에만 (PR=검증)</td>
</tr>
<tr>
<td>2</td>
<td><code>relation ... does not exist</code>, Flyway 로그 없음</td>
<td>Spring Boot 4.x <code>spring-boot-flyway</code> 모듈 누락</td>
<td>그 모듈 의존성 추가</td>
</tr>
<tr>
<td>3</td>
<td><code>permission denied for database</code></td>
<td>마이그레이션 <code>CREATE SCHEMA</code> 권한 부족</td>
<td>스키마 사전생성(소유자=앱유저) + <code>create-schemas=false</code></td>
</tr>
<tr>
<td>4</td>
<td><code>new row violates row-level security policy</code> / 목록 0건</td>
<td>set_config 가드가 DataSource 키 충돌로 매번 skip</td>
<td>가드 제거, 매번 set_config</td>
</tr>
<tr>
<td>5</td>
<td><code>A result was returned when none was expected</code></td>
<td><code>SELECT set_config</code>를 <code>update()</code>로 실행</td>
<td><code>queryForObject</code>로 실행</td>
</tr>
<tr>
<td>6</td>
<td>(위가 배포 후에야 드러남)</td>
<td>RLS 통합테스트를 로컬에서 안 돌림</td>
<td>Docker로 통합테스트 검증 습관화</td>
</tr>
</tbody></table>
<hr>
<h2 id="한-줄-요약">한 줄 요약</h2>
<blockquote>
<p>하나의 500 뒤에 <strong>CI 배포 · Flyway 모듈 · DB 권한 · RLS 세션변수 배선 · JDBC 실행 방식</strong>까지 5겹의
원인이 겹쳐 있었고, 대부분 <strong>에러 없이 조용히</strong> 실패해서 배포해봐야 하나씩 드러났다. 근본 원인은
<strong>&quot;RLS 경로를 실제 DB로 검증하지 않은 것&quot;</strong> 하나였고, Docker로 통합테스트를 돌리자 남은 버그가 배포
전에 다 잡혔다.</p>
</blockquote>
<h2 id="재발-방지-체크리스트">재발 방지 체크리스트</h2>
<ul>
<li><input disabled="" type="checkbox"> DB/RLS 변경 → 올리기 전에 <code>./gradlew :app:test --tests &quot;*HelpDeskRlsIntegrationTest&quot;</code> (Docker)</li>
<li><input disabled="" type="checkbox"> Spring Boot 4.x에서 어떤 기술 쓰면 <strong><code>spring-boot-&lt;기술&gt;</code> autoconfig 모듈</strong>도 넣었는지 확인</li>
<li><input disabled="" type="checkbox"> 마이그레이션은 앱 유저 권한 안에서만(새 스키마/확장 등은 DBA)</li>
<li><input disabled="" type="checkbox"> <code>TransactionSynchronizationManager</code> resource 키로 DataSource 쓰지 않기</li>
<li><input disabled="" type="checkbox"> 결과 반환 SQL은 <code>query</code>/<code>queryForObject</code>로 (update 금지)</li>
<li><input disabled="" type="checkbox"> 500이 반복되면 <strong><code>kubectl logs</code>의 첫 <code>Caused by:</code></strong> 부터 본다 (500 메시지는 다 똑같이 생겼다)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[ADR-0001: 왜 RLS 세션변수를 "데이터 접근 순간"에 설정하는가]]></title>
            <link>https://velog.io/@gitit-sehyeon/ADR-0001-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-%EC%99%9C-RLS-%EC%84%B8%EC%85%98%EB%B3%80%EC%88%98%EB%A5%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%91%EA%B7%BC-%EC%88%9C%EA%B0%84%EC%97%90-%EC%84%A4%EC%A0%95%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@gitit-sehyeon/ADR-0001-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-%EC%99%9C-RLS-%EC%84%B8%EC%85%98%EB%B3%80%EC%88%98%EB%A5%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%91%EA%B7%BC-%EC%88%9C%EA%B0%84%EC%97%90-%EC%84%A4%EC%A0%95%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Tue, 28 Jul 2026 06:09:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/1a4e8c11-46f5-4de5-9efe-7ae2166dfb1e/image.png" alt=""></p>
<blockquote>
<p>이 문서는 원본 ADR-0001(RLS 테넌트 세션변수를 Aspect가 아니라 데이터 접근 경로에서 설정한다)을 예시 코드와 함께 처음부터 차근차근 풀어 설명합니다.</p>
</blockquote>
<hr>
<h2 id="1-배경-지금-우리가-뭘-하려는-건가">1. 배경: 지금 우리가 뭘 하려는 건가</h2>
<p>헬프데스크 서비스는 여러 회사(테넌트)가 <strong>같은 데이터베이스, 같은 테이블</strong>을 공유해서 씁니다. 예를 들어 티켓 테이블 하나에 A회사 티켓과 B회사 티켓이 같이 들어 있어요.</p>
<pre><code class="language-sql">CREATE TABLE ticket (
  id          bigint PRIMARY KEY,
  tenant_id   text NOT NULL,
  title       text,
  ...
);</code></pre>
<p>문제는: A회사 로그인 유저가 쿼리를 날렸을 때 실수로라도 B회사 티켓이 보이면 절대 안 됩니다. 이걸 애플리케이션 코드(<code>WHERE tenant_id = ?</code>를 매번 붙이는 방식)로 막으면, 개발자가 딱 한 줄 빠뜨리는 순간 데이터가 샙니다. 사람이 실수하기 쉬운 방식이에요.</p>
<p>그래서 <strong>RLS(Row Level Security)</strong>를 씁니다. DB 자체에 &quot;이 테이블은 현재 세션의 tenant_id와 일치하는 행만 보여준다&quot;는 규칙을 박아버리는 겁니다. 애플리케이션 코드가 실수해도 DB가 막아줍니다.</p>
<pre><code class="language-sql">ALTER TABLE ticket ENABLE ROW LEVEL SECURITY;
ALTER TABLE ticket FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON ticket
  USING (tenant_id = current_setting(&#39;app.tenant_id&#39;, true))
  WITH CHECK (tenant_id = current_setting(&#39;app.tenant_id&#39;, true));</code></pre>
<p>여기서 <code>current_setting(&#39;app.tenant_id&#39;, true)</code>가 핵심입니다. <strong>&quot;지금 이 DB 커넥션에 누가 로그인해 있는지&quot;를 저장해두는 세션 변수</strong>를 읽는 겁니다. 즉 이 변수를 누가, 언제, 어디서 채워 넣느냐가 이 ADR의 전부입니다.</p>
<hr>
<h2 id="2-왜-이게-위험한-문제인가">2. 왜 이게 위험한 문제인가</h2>
<p>이 세션변수는 이렇게 설정합니다.</p>
<pre><code class="language-sql">SET LOCAL app.tenant_id = &#39;tenant-A&#39;;</code></pre>
<p><code>SET LOCAL</code>은 두 가지 제약이 있습니다.</p>
<ol>
<li><strong>현재 트랜잭션 안에서만 유효</strong>하고, 커밋/롤백하면 자동으로 사라집니다. (좋은 점 — 커넥션 풀에 반납된 다음 다른 테넌트가 그 커넥션을 재사용해도 값이 안 남습니다.)</li>
<li>하지만 <strong>&quot;실제 쿼리가 나가는 그 커넥션, 그 트랜잭션&quot;에서 설정해야만</strong> 의미가 있습니다.</li>
</ol>
<p>만약 엉뚱한 커넥션에서 설정하거나, 트랜잭션이 시작되기 전(autocommit 상태)에 설정해버리면 어떻게 될까요?</p>
<pre><code class="language-java">// 트랜잭션 밖에서 SET LOCAL을 실행한 경우
jdbcTemplate.execute(&quot;SET LOCAL app.tenant_id = &#39;tenant-A&#39;&quot;); // autocommit 커넥션에 설정됨
// → 이 문장이 끝나자마자 값이 사라짐 (autocommit이라 즉시 커밋된 것과 같음)

// 이후 실제 쿼리는 다른 커넥션을 풀에서 받아옴
List&lt;Ticket&gt; tickets = ticketRepository.findAll();
// current_setting(&#39;app.tenant_id&#39;, true) → NULL</code></pre>
<p>여기서 진짜 무서운 부분: <code>tenant_id = NULL</code> 비교는 SQL에서 <strong>참도 거짓도 아닌 NULL</strong>이 됩니다. <code>USING</code> 절이 NULL이 되면 Postgres는 그 행을 그냥 &quot;안 보여줌&quot; 처리합니다. 즉:</p>
<ul>
<li>데이터가 새는 방향이 아니라 <strong>전부 안 보이는 방향</strong>으로 조용히 실패합니다.</li>
<li>에러가 안 납니다. 예외도 안 던져집니다. 로그도 안 남습니다.</li>
<li>개발자가 &quot;어? 왜 데이터가 하나도 안 나오지?&quot;하고 한참 삽질해야 원인을 찾습니다.</li>
</ul>
<p>이게 이 ADR이 &quot;침묵 실패(silent failure)&quot;라고 부르는 상황입니다. <strong>설정 순서를 한 곳이라도 잘못 짜면, 컴파일도 되고 배포도 되고 테스트도 어쩌다 통과하는데, 프로덕션에서 조용히 오작동</strong>합니다. 그래서 &quot;어디서 설정하느냐&quot;를 신중하게 골라야 합니다.</p>
<hr>
<h2 id="3-검토했지만-버린-대안들">3. 검토했지만 버린 대안들</h2>
<h3 id="대안-a-aspect로-트랜잭션-시작-시-자동-설정">대안 A: <code>@Aspect</code>로 트랜잭션 시작 시 자동 설정</h3>
<pre><code class="language-java">@Aspect
@Component
public class TenantContextAspect {

    @Before(&quot;@annotation(org.springframework.transaction.annotation.Transactional)&quot;)
    public void setTenantContext(JoinPoint jp) {
        String tenantId = TenantContext.getCurrentTenant();
        jdbcTemplate.execute(&quot;SET LOCAL app.tenant_id = &#39;&quot; + tenantId + &quot;&#39;&quot;);
    }
}</code></pre>
<p>얼핏 깔끔해 보이지만 문제는 <strong>Spring이 advice(부가기능)들의 실행 순서를 보장하는 방식</strong> 때문에 생깁니다. Spring의 트랜잭션 관리 자체도 내부적으로 <code>TransactionInterceptor</code>라는 advice로 동작합니다. 그리고 Spring 공식 문서에는 이렇게 나와 있습니다.</p>
<blockquote>
<p><code>Ordered.LOWEST_PRECEDENCE</code>(가장 낮은 우선순위)로 설정하면 트랜잭션 advice가 <strong>가장 안쪽</strong>에서 실행된다.</p>
</blockquote>
<p>즉 커스텀 aspect가 트랜잭션 advice보다 <strong>바깥쪽</strong>에서 실행되도록 순서를 지정해두지 않으면, 이런 순서로 실행될 수 있습니다.</p>
<pre><code>1. TenantContextAspect.setTenantContext()  ← 아직 트랜잭션 시작 안 됨! (autocommit 커넥션)
2. TransactionInterceptor가 진짜 트랜잭션 시작
3. 실제 비즈니스 로직 실행 (이때는 새 트랜잭션/커넥션)</code></pre><p>1번에서 SET LOCAL을 실행한 커넥션과, 3번에서 실제 쿼리가 나가는 커넥션이 다를 수 있습니다. 이걸 고치려면 <code>@EnableTransactionManagement(order=...)</code> 같은 설정을 정확히 맞춰야 하는데, 이건 <strong>개발자가 나중에 실수로 건드리기 매우 쉬운 설정</strong>입니다. 그리고 잘못 건드려도 2번에서 말한 것처럼 &quot;조용히&quot; 실패합니다. → 위험해서 기각.</p>
<h3 id="대안-b-transactionsynchronization으로-트랜잭션-시작-직후-설정">대안 B: <code>TransactionSynchronization</code>으로 트랜잭션 시작 직후 설정</h3>
<pre><code class="language-java">TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterBegin() {  // ← 이런 메서드는 존재하지 않음
        jdbcTemplate.execute(&quot;SET LOCAL app.tenant_id = &#39;&quot; + tenantId + &quot;&#39;&quot;);
    }
});</code></pre>
<p>이 방식은 Spring의 <code>TransactionSynchronization</code> 인터페이스에 &quot;트랜잭션 시작 직후&quot;를 의미하는 콜백이 <strong>아예 존재하지 않아서</strong> 구현 자체가 불가능합니다. 실제로 있는 콜백은 이것뿐입니다.</p>
<pre><code class="language-java">public interface TransactionSynchronization {
    void beforeCommit(boolean readOnly);   // 커밋 직전
    void beforeCompletion();               // 완료(커밋/롤백) 직전
    void afterCommit();                    // 커밋 후
    void afterCompletion(int status);      // 완료 후
}</code></pre>
<p>전부 트랜잭션 &quot;끝나갈 때&quot; 또는 &quot;끝난 후&quot;에 관한 콜백이지, &quot;막 시작했을 때&quot;를 잡아주는 게 없습니다. 우리가 원하는 건 &quot;트랜잭션 시작 직후 SET&quot;인데 그걸 걸 자리가 없는 것 — 그래서 부분 기각.</p>
<hr>
<h2 id="4-채택한-방안-c-데이터-접근-경로에서-직접-설정">4. 채택한 방안 (C): 데이터 접근 경로에서 직접 설정</h2>
<p>핵심 아이디어: <strong>트랜잭션 시작 이벤트를 별도로 잡으려 하지 말고, 실제로 쿼리를 실행하는 바로 그 코드 안에서 세션변수를 설정한다.</strong></p>
<pre><code class="language-java">@Service
public class TicketService {

    @Autowired
    private JdbcTemplate jdbcTemplate; // Spring이 관리하는 표준 JdbcTemplate

    @Transactional
    public List&lt;Ticket&gt; getTickets(String tenantId) {
        // 트랜잭션의 &quot;첫 DB 접근&quot; 시점에 직접 설정
        jdbcTemplate.update(
            &quot;SELECT set_config(&#39;app.tenant_id&#39;, ?, true)&quot;, // 세 번째 인자 true = LOCAL과 동일 효과
            tenantId
        );

        return ticketRepository.findAll(); // 이후 쿼리들은 같은 커넥션을 그대로 씀
    }
}</code></pre>
<p>이 방식이 안전한 이유는 <code>JdbcTemplate</code>의 동작 방식 때문입니다.</p>
<pre><code class="language-java">// Spring 내부에서 JdbcTemplate이 커넥션을 가져오는 방식 (개념적으로)
Connection con = DataSourceUtils.getConnection(dataSource);</code></pre>
<p><code>DataSourceUtils.getConnection()</code>은 <strong>&quot;지금 활성화된 트랜잭션에 이미 바인딩되어 있는 커넥션&quot;</strong>을 그대로 반환합니다. 새 커넥션을 풀에서 받아오는 게 아니라, 이 트랜잭션이 계속 써온(혹은 처음 시작하는) 바로 그 커넥션이에요. 그래서:</p>
<ul>
<li><code>@Transactional</code> 메서드 안에서 <code>jdbcTemplate</code>으로 <code>set_config</code>를 실행하면 → <strong>반드시 실제 쿼리가 나갈 그 커넥션</strong>에 설정됩니다.</li>
<li>순서를 신경 쓸 필요가 없습니다. aspect 순서, advice 우선순위 이런 거 상관없이, &quot;이 메서드가 호출되는 시점 = 트랜잭션이 이미 시작돼 있고 커넥션도 바인딩된 시점&quot;이기 때문입니다.</li>
</ul>
<p><code>TransactionSynchronizationManager.isActualTransactionActive()</code>는 이 흐름에서 &quot;세션변수를 몇 번 설정했는지 세는 용도&quot;가 아니라, <strong>&quot;지금 진짜 트랜잭션 안에 있는지&quot;를 확인하는 가드</strong>로만 씁니다. 리소스(커넥션) 바인딩 여부를 트랜잭션당 1회 확인하는 정도입니다.</p>
<pre><code class="language-java">public void ensureTenantContext(String tenantId) {
    if (!TransactionSynchronizationManager.isActualTransactionActive()) {
        throw new IllegalStateException(
            &quot;트랜잭션 밖에서 데이터 접근 시도 - RLS 세션변수를 설정할 수 없음&quot;
        );
    }
    jdbcTemplate.update(&quot;SELECT set_config(&#39;app.tenant_id&#39;, ?, true)&quot;, tenantId);
}</code></pre>
<hr>
<h2 id="5-정리-abc안-비교표">5. 정리: A/B/C안 비교표</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>설정 시점</th>
<th>문제점</th>
</tr>
</thead>
<tbody><tr>
<td>A. <code>@Aspect</code></td>
<td><code>@Transactional</code> 진입 시</td>
<td>advice 순서에 의존 → 순서 꼬이면 트랜잭션 밖에서 설정될 수 있음</td>
</tr>
<tr>
<td>B. <code>TransactionSynchronization</code></td>
<td>트랜잭션 시작 직후</td>
<td>그런 콜백(afterBegin)이 애초에 없음 → 구현 불가</td>
</tr>
<tr>
<td><strong>C. 데이터 접근 경로 (채택)</strong></td>
<td>실제 쿼리 실행 직전</td>
<td><code>JdbcTemplate</code>이 현재 트랜잭션의 커넥션을 그대로 쓰므로 항상 정확</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-이-결정으로-얻는-것--여전히-지켜야-하는-것">6. 이 결정으로 얻는 것 / 여전히 지켜야 하는 것</h2>
<p><strong>장점</strong></p>
<ul>
<li>advice 실행 순서에 의존하지 않음 → 설정 실수로 인한 조용한 실패 위험 제거</li>
<li>커넥션 풀 재사용 시에도 테넌트 값이 섞이지 않음 (SET LOCAL이 트랜잭션 종료 시 자동 리셋되기 때문)</li>
<li>AOP(관점지향 프로그래밍) 설정에 대한 암묵적 의존이 사라짐</li>
</ul>
<p><strong>여전히 지켜야 할 규칙 (제약)</strong></p>
<ul>
<li>세션변수를 읽는 모든 코드 경로는 <strong>반드시 <code>@Transactional</code> 경계 안에서</strong> 일어나야 합니다. 서비스 계층에서 감싸야 하고, 조회만 하는 로직도 예외가 아닙니다.</li>
<li>만약 실수로 트랜잭션 밖에서 DB에 접근하면 → 이번엔 조용히 통과되는 게 아니라, <code>FORCE ROW LEVEL SECURITY</code> + <code>USING</code>/<code>WITH CHECK</code> 정책 때문에 <strong>조회 결과가 0건으로 확실하게 막힙니다(fail-closed)</strong>. 즉 사고가 나도 티가 나게 실패합니다.</li>
<li>검증은 두 테넌트 상호 격리 테스트 + 세션변수 미설정 시 <code>fail-closed</code>가 되는지를 <code>@SpringBootTest</code>(Testcontainers Postgres)로 확인해서, 일반 룰로 고정합니다.</li>
</ul>
<hr>
<h2 id="한-줄-요약">한 줄 요약</h2>
<blockquote>
<p>&quot;트랜잭션 시작할 때 미리 세션변수를 걸어두자&quot;는 방식들은 순서 문제로 조용히 실패할 위험이 있어서 버렸고, &quot;쿼리가 실제로 나가는 그 순간, 그 쿼리가 쓰는 진짜 커넥션에 직접 설정하자&quot;로 결정했습니다. 그리고 혹시 설정을 빠뜨리면 데이터가 새는 게 아니라 아예 안 보이도록(fail-closed) 만들어서, 실수해도 안전한 쪽으로 넘어지게 설계했습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[hearth 전체 정리 — 뭐가 있고, 뭘 하고, 어떻게 배포하나]]></title>
            <link>https://velog.io/@gitit-sehyeon/hearth-%EC%A0%84%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%AD%90%EA%B0%80-%EC%9E%88%EA%B3%A0-%EB%AD%98-%ED%95%98%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%82%98</link>
            <guid>https://velog.io/@gitit-sehyeon/hearth-%EC%A0%84%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%AD%90%EA%B0%80-%EC%9E%88%EA%B3%A0-%EB%AD%98-%ED%95%98%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%82%98</guid>
            <pubDate>Fri, 24 Jul 2026 05:28:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>코드(<code>backend/app/*.py</code>, <code>k8s/*.yaml</code>)와 이 머신의 실제 실행 상태를 직접 확인해서 작성.
검증 시각: 2026-07-24.</p>
</blockquote>
<h2 id="1-한-줄-요약">1. 한 줄 요약</h2>
<p><strong>내 맥(mac) 하나를 &quot;미니 프로덕션 인프라&quot;처럼 운영하기 위한 로컬 컨트롤 플레인.</strong>
여러 로컬 서비스(agent, 웹앱)를 <code>localhost/&lt;이름&gt;</code> 규칙으로 통일해서 라우팅하고,
포트/docker/colima/k3s 상태를 모니터링하고, 공유 DB 발급과 LLM 실행 창구까지 대신 해준다.</p>
<h2 id="2-전체-아키텍처">2. 전체 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/50f87c88-6992-41d9-b892-84225fef8b79/image.png" alt=""></p>
<pre><code>                         사람 (웹 UI)               agent (MCP)            스크립트(REST)
                              │                          │                       │
                              └──────────────┬───────────┴───────────────────────┘
                                              ▼
                              hearth 백엔드 (FastAPI, :8080, launchd 상시기동)
                                              │
        ┌───────────────┬────────────────────┼───────────────────┬──────────────────┐
        ▼               ▼                    ▼                   ▼                  ▼
   nginx 제어        관측/대시보드         시스템 모니터링       k8s 제어 평면       LLM 게이트웨이
  (service.py/        (metrics.py:        (sysmon.py:         (k8s.py, provision.py)  (llm.py:
   nginx.py)           백그라운드 샘플러,   포트/docker/          k8s_status,          claude/codex CLI
                        SQLite events)      colima lsof/ps)      k8s_apply,            를 호스트에서
                                                                  provision_db)         대신 실행)
        │                                                             │
        ▼                                                             ▼
  host nginx (:80)                                            colima VM → k3s 클러스터
  runtime/nginx/conf.d/routes.conf                             ├─ ns default: Gateway API + NGF
  (프로세스로 뜬 로컬 서비스 라우팅)                              │   (HTTPRoute 로 서비스별 등록)
        │                                                       └─ ns data: 공유 PostgreSQL(pgvector)
        ▼                                                              (provision_db 로 서비스별 DB 발급)
  127.0.0.1:&lt;포트&gt; (agent-a, agent-b, ...)                       └─ NodePort 30321 ← host nginx 가 프록시</code></pre><p>핵심 구도: <strong>&quot;로컬 프로세스로 대충 띄운 서비스&quot;</strong> 와 <strong>&quot;k3s 에 컨테이너로 제대로 배포한 서비스&quot;</strong>를
같은 <code>http://localhost/&lt;서비스명&gt;/</code> 규칙 아래 하나의 <code>:80</code> 진입점으로 통합한다.</p>
<h2 id="3-구성-요소-실제-코드-기준">3. 구성 요소 (실제 코드 기준)</h2>
<table>
<thead>
<tr>
<th>모듈</th>
<th>파일</th>
<th>하는 일</th>
</tr>
</thead>
<tbody><tr>
<td>라우팅 코어</td>
<td><code>service.py</code>, <code>nginx.py</code></td>
<td>서비스/라우트 CRUD → nginx 설정 렌더 → <code>nginx -t</code> 검증 → 반영 → reload</td>
</tr>
<tr>
<td>REST API</td>
<td><code>rest.py</code></td>
<td><code>/api/*</code> 전체 엔드포인트 (아래 4절)</td>
</tr>
<tr>
<td>MCP 서버</td>
<td><code>mcp_server.py</code></td>
<td>agent 가 도구 호출로 서비스 등록/조회/nginx 제어</td>
</tr>
<tr>
<td>관측 대시보드</td>
<td><code>metrics.py</code></td>
<td>10초 주기 백그라운드 샘플러 — 라우트별 liveness, access.log 파싱(req/status/지연), 머신 리소스, 상태 전이 이벤트(SQLite 영속)</td>
</tr>
<tr>
<td>시스템 모니터링</td>
<td><code>sysmon.py</code></td>
<td>LISTEN 포트 조회, docker 컨테이너 상태, colima 라이프사이클(start/stop, cpu/mem 지정) — 전부 로컬 CLI 서브프로세스 파싱</td>
</tr>
<tr>
<td>k8s 제어</td>
<td><code>k8s.py</code></td>
<td>클러스터 상태 조회(<code>k8s_status</code>), 리소스 조회/apply(<code>k8s_get</code>/<code>k8s_apply</code>), 서비스용 HTTPRoute manifest 생성</td>
</tr>
<tr>
<td>DB 프로비저닝</td>
<td><code>provision.py</code></td>
<td>공유 PostgreSQL 에 서비스 전용 DB+ROLE 최소권한 발급 (<code>provision_db</code>)</td>
</tr>
<tr>
<td>LLM 게이트웨이</td>
<td><code>llm.py</code></td>
<td>k8s 파드는 claude/codex 인증이 없으므로, 파드가 <code>/api/llm</code> 호출 → hearth 가 호스트에서 대신 실행해 응답만 반환</td>
</tr>
<tr>
<td>영속화</td>
<td><code>db.py</code>, <code>persistence.py</code></td>
<td>SQLite(<code>runtime/hearth.db</code>) — 라우트/서비스/이벤트/발급된 DB 정보</td>
</tr>
<tr>
<td>프론트엔드</td>
<td><code>frontend/</code></td>
<td>React+Vite UI, 백엔드가 <code>dist/</code> 서빙</td>
</tr>
<tr>
<td>상시 기동</td>
<td><code>launchd/com.hearth.plist</code></td>
<td>로그인 시 자동 기동 + <code>KeepAlive</code>(죽으면 재시작), <code>AUTO_START_NGINX=1</code></td>
</tr>
</tbody></table>
<blockquote>
<p><code>HANDOFF.md</code> 에 &quot;로드맵&quot;으로 적힌 포트/docker/colima 모니터링은 <strong>이미 구현·배포돼 있음</strong>
(<code>sysmon.py</code> + <code>rest.py</code> 의 <code>/ports</code>, <code>/docker/containers</code>, <code>/colima/*</code>). 그 문서는 갱신이 필요한 구버전 메모.</p>
</blockquote>
<h2 id="4-rest-api-한눈에-api">4. REST API 한눈에 (<code>/api/*</code>)</h2>
<table>
<thead>
<tr>
<th>그룹</th>
<th>엔드포인트</th>
</tr>
</thead>
<tbody><tr>
<td>서비스/라우트</td>
<td><code>services</code>, <code>services/{name}/guide</code>, <code>routes</code>, <code>routes/{id}</code>, <code>routes/{id}/toggle</code></td>
</tr>
<tr>
<td>nginx 제어</td>
<td><code>nginx/status</code>, <code>nginx/{start,stop,reload,apply,validate,install}</code></td>
</tr>
<tr>
<td>포트</td>
<td><code>ports</code>, <code>ports/free</code>, <code>ports/{port}</code></td>
</tr>
<tr>
<td>docker/colima</td>
<td><code>docker/containers</code>, <code>colima/status</code>, <code>colima/{start,stop}</code></td>
</tr>
<tr>
<td>ollama</td>
<td><code>ollama/status</code></td>
</tr>
<tr>
<td>k8s</td>
<td><code>k8s/status</code>, <code>k8s/resources</code>, <code>k8s/apply</code></td>
</tr>
<tr>
<td>DB 프로비저닝</td>
<td><code>provision</code>, <code>provision/{service}</code></td>
</tr>
<tr>
<td>LLM</td>
<td><code>llm</code></td>
</tr>
<tr>
<td>관측</td>
<td><code>dashboard</code>, <code>events</code>, <code>metrics/route</code>, <code>metrics/resources</code></td>
</tr>
</tbody></table>
<p>MCP 는 이 중 서비스 단위 도구(<code>register_service</code>, <code>unregister_service</code>, <code>list_services</code>)와
저수준 도구(<code>list_routes</code>, <code>add_route</code>, ...), nginx 제어(<code>get_nginx_status</code>, <code>reload_nginx</code>, <code>apply_config</code>)를 노출.</p>
<h2 id="5-지금-이-머신의-실제-상태-검증됨-2026-07-24-1420-kst">5. 지금 이 머신의 실제 상태 (검증됨, 2026-07-24 14:20 KST)</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>launchd <code>com.hearth</code></td>
<td>실행 중 (PID 2660)</td>
</tr>
<tr>
<td>hearth 백엔드 :8080</td>
<td>정상 응답</td>
</tr>
<tr>
<td>nginx :80</td>
<td>실행 중 (PID 2817, v1.31.3)</td>
</tr>
<tr>
<td>등록된 서비스(<code>/api/services</code>, hearth 자체 SQLite)</td>
<td><strong>없음</strong> (빈 배열 — <code>/hearth</code> self-route 만 존재)</td>
</tr>
<tr>
<td>colima</td>
<td>실행 중, kubernetes enabled</td>
</tr>
<tr>
<td>k3s 노드</td>
<td><code>colima</code> 1개, Ready, v1.35.0+k3s1</td>
</tr>
<tr>
<td>Gateway API / NGF</td>
<td><code>hearth-gw</code> Programmed=True, GatewayClass <code>nginx</code></td>
</tr>
<tr>
<td>공유 PostgreSQL (<code>ns data</code>)</td>
<td><code>postgres-0</code> Running</td>
</tr>
<tr>
<td>k8s HTTPRoute (<code>ns default</code>)</td>
<td><strong>6개 존재</strong>: <code>control-tower</code>, <code>doorayaas</code>, <code>interviewer</code>, <code>keycloak</code>, <code>research-builder</code>, <code>skm-tool</code></td>
</tr>
</tbody></table>
<p><strong>주목할 점</strong>: k8s 쪽엔 이미 서비스 6개가 HTTPRoute 로 라우팅되고 있는데, hearth 자체의
서비스 레지스트리(<code>/api/services</code>, host nginx <code>routes.conf</code>)에는 하나도 없다. 즉 이 6개는
hearth 의 <code>register_service</code>/<code>k8s_httproute_manifest</code> 를 거치지 않고 <strong>수동으로 <code>kubectl apply</code></strong> 된
것으로 보인다 — host nginx 의 <code>:80 → NodePort 30321(Gateway)</code> 프록시 라우트 자체도 아직 없어서,
<strong>지금 이 6개 서비스는 <code>localhost/&lt;이름&gt;</code> 으로는 아직 접근이 안 될 가능성이 높다</strong> (Gateway 의
NodePort 로 직접 가야 함). 실제로 쓰려면 6절의 &quot;host nginx → Gateway 프록시&quot; 단계가 빠졌는지 확인 필요.</p>
<h2 id="6-실제-배포-방법">6. 실제 배포 방법</h2>
<h3 id="6-a-새-머신에서-hearth-자체를-처음-띄우기">6-A. 새 머신에서 hearth 자체를 처음 띄우기</h3>
<pre><code class="language-bash"># 1) 의존 도구
brew install colima docker kubectl helm nginx node python@3.14

# 2) 80포트 제어용 무비번 sudo (1회)
./scripts/setup-sudoers.sh

# 3) 백엔드 기동 (venv 자동 생성 + 프론트 빌드 + uvicorn :8080)
./scripts/start.sh
# → http://localhost:8080 접속 확인

# 4) 상시 기동 등록 (선택, 로그인 시 자동 기동)
cp launchd/com.hearth.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.hearth.plist</code></pre>
<h3 id="6-b-그냥-로컬-프로세스로-뜬-서비스-등록-컨테이너k8s-불필요">6-B. &quot;그냥 로컬 프로세스&quot;로 뜬 서비스 등록 (컨테이너/k8s 불필요)</h3>
<p>가장 흔한 경로. FE 는 <code>npm run dev</code>/<code>vite</code>, BE 는 아무 포트로나 로컬 실행 중이라고 가정.</p>
<pre><code class="language-bash">curl -X POST localhost:8080/api/services -H &#39;Content-Type: application/json&#39; \
  -d &#39;{&quot;service&quot;:&quot;twitter&quot;,&quot;frontend_port&quot;:5001,&quot;backend_port&quot;:8001,&quot;mcp_port&quot;:8001}&#39;
# → http://localhost/twitter/       (FE)
# → http://localhost/twitter/api/   (BE)
# → http://localhost/twitter/mcp    (MCP)</code></pre>
<ul>
<li>FE 는 base path <code>/twitter</code> 로 빌드돼 있어야 함(Vite <code>base</code>, Next.js <code>basePath</code>). 아니면 <code>strip=true</code> 로 등록.</li>
<li>등록 직후 자동으로 <code>nginx -t</code> 검증 → 통과 시 live 반영 → reload. 실패해도 기존 nginx 는 안 깨짐.</li>
</ul>
<h3 id="6-c-k3s-에-컨테이너로-배포하는-서비스-제대로-된-배포">6-C. &quot;k3s 에 컨테이너로 배포&quot;하는 서비스 (제대로 된 배포)</h3>
<p>전제: colima k3s + Gateway API/NGF + 공유 PostgreSQL 이 떠 있어야 함(없으면 아래 6-D).</p>
<pre><code class="language-bash"># 1) 이미지 빌드 — 레지스트리 push 불필요 (cri-dockerd 가 로컬 이미지를 그대로 봄)
docker build -t myservice:local .
# 배포 매니페스트에 imagePullPolicy: Never, image: myservice:local

# 2) DB 필요하면 발급
curl -X POST localhost:8080/api/provision -d &#39;{&quot;service&quot;:&quot;myservice&quot;}&#39;
# → DSN 반환, k8s Secret 으로 주입해서 서비스가 사용

# 3) Deployment/Service 배포
kubectl apply -f myservice/deploy.yaml

# 4) hearth 로 HTTPRoute manifest 생성 (register_service 의 k8s 짝)
curl -X POST localhost:8080/api/k8s/... # k8s_httproute_manifest 도구/엔드포인트로 생성 후 apply
kubectl apply -f -   # 생성된 manifest 적용

# 5) host nginx 를 Gateway NodePort 로 연결 (한 번만 하면 되고, 이미 되어 있는지 5절 참고)
kubectl get svc -n default -l gateway.networking.k8s.io/gateway-name=hearth-gw \
  -o jsonpath=&#39;{.items[0].spec.ports[0].nodePort}&#39;   # 예: 30321
curl -X POST localhost:8080/api/routes -d &#39;{&quot;path_prefix&quot;:&quot;/myservice&quot;,&quot;target_port&quot;:30321}&#39;</code></pre>
<h3 id="6-d-k3s-제어-평면-자체가-아직-없는-경우-클러스터부터-구축">6-D. k3s 제어 평면 자체가 아직 없는 경우 (클러스터부터 구축)</h3>
<pre><code class="language-bash"># 1) colima k3s 기동
colima start --kubernetes --cpu 6 --memory 12 --disk 100
kubectl config use-context colima

# 2) 공유 PostgreSQL
kubectl create namespace data
PW=$(openssl rand -hex 24)
kubectl create secret generic postgres-superuser -n data \
  --from-literal=POSTGRES_USER=postgres --from-literal=POSTGRES_PASSWORD=&quot;$PW&quot;
kubectl apply -f k8s/postgres/postgres.yaml

# 3) Gateway API + NGINX Gateway Fabric
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric --version 2.6.3 \
  -n nginx-gateway --create-namespace --wait
kubectl patch nginxproxy ngf-proxy-config -n nginx-gateway --type merge \
  -p &#39;{&quot;spec&quot;:{&quot;kubernetes&quot;:{&quot;service&quot;:{&quot;type&quot;:&quot;NodePort&quot;}}}}&#39;
kubectl apply -f k8s/gateway/gateway.yaml
kubectl apply -f k8s/gateway/httproutes.yaml</code></pre>
<p>(이 머신은 이미 이 단계까지 끝나 있음 — 5절 참고. 다만 host nginx ↔ Gateway NodePort 연결이
됐는지는 별도 확인 필요.)</p>
<h2 id="7-운영-명령-모음">7. 운영 명령 모음</h2>
<pre><code class="language-bash"># 상태
launchctl list | grep hearth
curl http://127.0.0.1:8080/api/health
tail -f runtime/backend.out.log

# 재시작 / 중지 (kill 은 KeepAlive 가 되살림 → bootout 사용)
launchctl kickstart -k gui/$(id -u)/com.hearth
launchctl bootout   gui/$(id -u)/com.hearth
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.hearth.plist

# Claude Code 에 MCP 로 연결
claude mcp add --transport http --scope user hearth http://localhost/hearth/mcp</code></pre>
<h2 id="8-확인이-필요한-것-다음에-볼-것">8. 확인이 필요한 것 (다음에 볼 것)</h2>
<ul>
<li>k8s 의 6개 HTTPRoute(<code>control-tower</code> 등)가 실제로 <code>localhost/&lt;이름&gt;</code> 으로 접근되는지,
아니면 host nginx ↔ Gateway NodePort 연결이 빠져서 죽어있는 라우트인지 확인.</li>
<li><code>HANDOFF.md</code> 가 구버전 상태(포트/docker/colima를 &quot;로드맵&quot;으로 기술)라 이 문서로 대체하거나 갱신 필요.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[민감 데이터 관리와 Secure Key Manager(SKM)]]></title>
            <link>https://velog.io/@gitit-sehyeon/%EB%AF%BC%EA%B0%90-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B4%80%EB%A6%AC%EC%99%80-Secure-Key-ManagerSKM</link>
            <guid>https://velog.io/@gitit-sehyeon/%EB%AF%BC%EA%B0%90-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B4%80%EB%A6%AC%EC%99%80-Secure-Key-ManagerSKM</guid>
            <pubDate>Fri, 24 Jul 2026 01:41:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/41bec451-8709-422f-8996-b5d810ffcc7a/image.png" alt=""></p>
<blockquote>
<p>AX개발팀 인턴 정세현 · 리서치 본문
발표일: 2025-08-04</p>
</blockquote>
<hr>
<h2 id="1-인트로--왜-키-관리인가">1. 인트로 — 왜 키 관리인가</h2>
<h3 id="문제-제기-데이터베이스는-언젠가-뚫린다">문제 제기: 데이터베이스는 언젠가 뚫린다</h3>
<p>방화벽, WAF, 접근제어를 아무리 촘촘히 해도 데이터 유출 사고는 계속 발생한다. SQL Injection, 내부자, 방치된 백업 파일, 클라우드 스토리지 오설정, 탈취된 관리자 계정 등 경로도 다양하다. 그래서 오늘날 보안 설계의 전제는 &quot;완벽히 막는다&quot;가 아니라 <strong>&quot;침해는 불가피하다(assume breach)&quot;</strong> 로 이동했다. 핵심 질문도 *&quot;어떻게 안 뚫리게 하지?&quot;* 에서 *&quot;뚫려도 데이터가 새지 않게 하려면?&quot;* 으로 바뀐다.</p>
<p>법적으로도 암호화는 선택이 아니다. 국내 개인정보보호법은 비밀번호·고유식별정보 등 민감 항목의 암호화 저장을 의무화하고 있다.</p>
<h3 id="암호화가-없으면--평문-그대로-노출">암호화가 없으면 = 평문 그대로 노출</h3>
<pre><code>[암호화 X]  DB 유출 → 홍길동 / 900101-1****** / password123   → 그대로 읽힘. 끝.
[암호화 O]  DB 유출 → 8f3a9c… / b21e77… / 5d41402a…           → 키가 없으면 무의미한 바이트.</code></pre><p>암호화의 목적은 &quot;유출을 막는 것&quot;이 아니라 <strong>&quot;유출됐을 때 무의미하게 만드는 것&quot;</strong>, 즉 최후의 방어선(last line of defense)이다.</p>
<h3 id="그러면-암호화-키는-어디에-두는가--이-문서의-주제">그러면 암호화 키는 어디에 두는가 — 이 문서의 주제</h3>
<p>암호화는 문제를 푸는 동시에 새 문제를 낳는다. 데이터를 잠갔어도 그것을 풀 <strong>키</strong>가 있어야 서비스가 돌아가는데, 그 키가 데이터 바로 옆(코드·DB·서버)에 있으면 유출 시 함께 털린다. 자물쇠를 채워도 열쇠를 문 옆 화분 밑에 두면 소용없다. 따라서 진짜 주제는 암호화 자체가 아니라 <strong>&quot;키를 어떻게, 어디에 둘 것인가&quot;</strong> 이다.</p>
<hr>
<h2 id="2-왜-키-관리가-어려운가">2. 왜 키 관리가 어려운가</h2>
<h3 id="순진한-저장-방법과-한계">순진한 저장 방법과 한계</h3>
<p><strong>하드코딩</strong> — 소스코드에 키를 박아두면 Git 저장소에 그대로 커밋되고, Git은 히스토리가 영구 보존되어 나중에 지워도 과거 커밋에 남는다. 배포된 바이너리를 디컴파일하면 문자열이 드러나며, 키를 바꾸려면 코드 수정→재빌드→재배포가 필요해 사실상 교체가 어렵다.</p>
<p><strong>설정 파일</strong>(<code>application.yml</code> 등) — 하드코딩보다 낫지만 파일은 여전히 평문이라 서버 침투 시 그대로 읽힌다. 설정 파일이 실수로 Git에 올라가는 사고도 흔하며, 파일에 접근 가능한 사람이 곧 키에 접근 가능한 사람이 되어 권한 분리가 안 된다.</p>
<p><strong>환경변수</strong> — 커밋 사고는 줄지만 <code>/proc/&lt;pid&gt;/environ</code>으로 같은 서버의 다른 프로세스가 조회할 수 있고, 에러 로그·크래시 덤프·APM 툴이 환경변수를 통째로 기록하는 경우가 있으며, 하위 프로세스로 자동 상속되어 의도치 않게 전파된다.</p>
<p>세 방법의 공통 문제는 <strong>키가 애플리케이션과 같은 신뢰 경계 안에 평문으로 존재한다</strong>는 점이다. 앱이 뚫리면 키도 함께 뚫린다.</p>
<h3 id="저장-안전성-≠-접근-통제">저장 안전성 ≠ 접근 통제</h3>
<p>키를 안전한 금고에 넣어도, 그 금고 문을 아무나 열 수 있으면 금고가 아니다. 저장의 안전성과는 별개로 <strong>&quot;이 요청이 정말 우리 서버가 보낸 것인가&quot;</strong> 를 검증하는 접근 통제(인증/인가)가 필요하다.</p>
<h3 id="키-교체순환와-기존-데이터-문제">키 교체(순환)와 기존 데이터 문제</h3>
<p>키는 유출·유출 의심, 컴플라이언스 요구, 인적 리스크 등으로 언젠가 반드시 교체해야 한다. 그런데 키를 바꾸면 딜레마가 생긴다.</p>
<pre><code>기존 데이터는 &quot;옛 키&quot;로 암호화돼 있음.
새 데이터는 &quot;새 키&quot;로 암호화하고 싶음.
→ 옛 데이터는 새 키로 못 푸는데?</code></pre><p>전체 데이터를 복호화 후 재암호화하면 수억 건 규모에서 수 시간~며칠이 걸리고, 모든 서버 설정을 동시에 교체해야 해 배포 부담도 크다. 결국 하드코딩·설정파일·환경변수는 <strong>(1) 저장 안전성, (2) 접근 통제, (3) 키 교체</strong> 세 가지를 동시에 만족시키지 못한다. 이 세 가지를 전담하는 시스템이 바로 <strong>KMS(Key Management Service)</strong> 이고, 그 구현체 중 하나가 NHN Cloud의 <strong>Secure Key Manager(SKM)</strong> 이다.</p>
<hr>
<h2 id="3-암호화-기초">3. 암호화 기초</h2>
<h3 id="대칭키-vs-비대칭키">대칭키 vs 비대칭키</h3>
<p><strong>대칭키</strong>(AES, ARIA 등)는 하나의 같은 키로 암호화와 복호화를 모두 수행한다. 연산이 가벼워 빠르므로 대용량 데이터 암·복호화에 적합하다. 약점은 상대에게 키를 안전하게 전달하는 것 자체가 어렵다는 점이다.</p>
<p><strong>비대칭키</strong>(RSA 등)는 공개키·개인키 한 쌍을 사용한다. 한쪽으로 잠그면 다른 쪽으로만 풀린다. 연산이 무거워 대용량에는 부적합하지만, 키를 미리 나누지 않아도 되고 무엇보다 <strong>서명/검증</strong>(개인키로 서명 → 공개키로 검증)으로 &quot;이 데이터가 위·변조되지 않았음&quot;을 증명할 수 있다.</p>
<p>정리하면 대칭키는 &quot;빠르지만 키 전달이 골칫거리&quot;, 비대칭키는 &quot;느리지만 키를 안 나눠도 되고 위·변조를 증명한다&quot;. 실무는 데이터 암호화에는 대칭키, 그 대칭키의 보호·전달에는 상위 키를 쓰는 하이브리드 방식을 취하며, 그 정점이 4장의 봉투 암호화다.</p>
<h3 id="skm이-데이터를-3종으로-나눈-이유">SKM이 데이터를 3종으로 나눈 이유</h3>
<p>NHN Cloud SKM은 보관 데이터를 <strong>기밀 데이터 / 대칭 키 / 비대칭 키</strong> 세 가지로 구분한다. 사용자의 니즈가 세 가지이기 때문이다.</p>
<table>
<thead>
<tr>
<th>데이터 유형</th>
<th>무엇인가</th>
<th>대표 용도</th>
<th>API</th>
</tr>
</thead>
<tbody><tr>
<td>기밀 데이터</td>
<td>임의 민감 텍스트(32KB 이하)</td>
<td>DB 접속정보, 앱키 등 값 보관</td>
<td>조회</td>
</tr>
<tr>
<td>대칭 키</td>
<td>AES-256(공공기관용 ARIA-256)</td>
<td>데이터 암호화/복호화</td>
<td>암호화·복호화·로컬키 생성·조회</td>
</tr>
<tr>
<td>비대칭 키</td>
<td>RSA 계열 키 쌍</td>
<td>데이터 서명/검증</td>
<td>서명·검증</td>
</tr>
</tbody></table>
<p>① 값을 그냥 안전하게 보관하고 싶다 → 기밀 데이터, ② 암·복호화하고 싶다 → 대칭 키, ③ 위·변조를 검증하고 싶다 → 비대칭 키. 중요한 점은 대칭 키·비대칭 키의 <strong>키 값 자체가 클라이언트로 직접 노출되지 않고 오직 API를 통해서만 사용</strong>된다는 것이다. &quot;키를 받아와 내가 쓴다&quot;가 아니라 &quot;SKM에게 작업을 시킨다&quot;에 가깝고, 덕분에 키가 외부로 새지 않으며 키 순환도 클라이언트 코드 변경 없이 가능하다.</p>
<hr>
<h2 id="4-봉투-암호화envelope-encryption">4. 봉투 암호화(Envelope Encryption)</h2>
<h3 id="왜-필요한가">왜 필요한가</h3>
<p>가장 순진한 발상은 &quot;데이터를 통째로 KMS에 보내 암호화해 달라&quot;는 것이다. 그러나 KMS/HSM은 보통 작은 데이터만 직접 암호화하도록 설계돼 있고(예: AWS KMS는 직접 암호화 4KB 한계), 대용량을 매번 네트워크로 왕복시키면 느리고 KMS가 병목이 된다. 봉투 암호화는 이를 뒤집는다. <strong>데이터는 내 서버에서 빠른 로컬 키(DEK)로 직접 암호화하고, 그 DEK만 KMS의 상위 키(KEK)로 암호화해 보관</strong>한다. 네트워크에는 작은 키만 오가고, 무거운 데이터 암호화는 로컬에서 처리된다.</p>
<h3 id="두-개의-키-dek와-kek">두 개의 키: DEK와 KEK</h3>
<table>
<thead>
<tr>
<th>약어</th>
<th>이름</th>
<th>역할</th>
<th>위치</th>
</tr>
</thead>
<tbody><tr>
<td>DEK</td>
<td>Data Encryption Key</td>
<td>실제 데이터를 암호화</td>
<td>애플리케이션에서 잠깐 사용 후 폐기</td>
</tr>
<tr>
<td>KEK</td>
<td>Key Encryption Key</td>
<td>DEK를 암호화(보호)</td>
<td>KMS/SKM 내부에만 존재, 밖으로 안 나감</td>
</tr>
</tbody></table>
<p>핵심 규칙은 세 가지다. <strong>평문 DEK는 저장하지 않고</strong> 사용 즉시 메모리에서 폐기한다. 저장하는 것은 <strong>암호화된 DEK</strong>뿐이며 이는 데이터 옆에 두어도 KEK 없이는 풀 수 없다. <strong>KEK는 KMS 밖으로 절대 나오지 않으며</strong>, DEK 복호화는 항상 KMS에게 요청한다.</p>
<ul>
<li>클라이언트가 KMS에 &quot;DEK 하나 만들어줘&quot;라고 요청한다 (아직 DEK가 없는 상태).</li>
<li>KMS가 내부적으로 평문 DEK를 생성하고, 그 자리에서 KEK로 암호화한 버전도 함께 만든다.</li>
<li>KMS는 두 가지를 클라이언트에게 반환한다: 평문 DEK, 암호화된 DEK.</li>
<li>클라이언트는 평문 DEK로 데이터를 암호화한 뒤 즉시 메모리에서 폐기하고, 암호화된 DEK만 데이터 옆에 저장한다.</li>
<li>암호화된 DEK를 저장소에서 꺼낸다 → KMS에 &quot;이 암호화된 DEK를 복호화해줘&quot;라고 요청 → KMS가 내부의 KEK로 풀어서 평문 DEK를 반환 → 그 평문 DEK로 데이터를 복호화 → 다 쓰고 나면 메모리에서 즉시 폐기.</li>
</ul>
<h3 id="암호화-·-복호화-플로우">암호화 · 복호화 플로우</h3>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/69ac3ad6-2319-4536-a703-4c0c570ba9f1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/a8c882e3-2e80-4c9c-838a-7e696b1b5bcd/image.png" alt=""></p>
<p>네트워크를 타는 것은 항상 작은 DEK뿐이고, 무거운 데이터는 로컬에서 처리된다는 것이 봉투 암호화의 핵심이다.</p>
<h3 id="구조의-이점">구조의 이점</h3>
<p><strong>성능</strong> — 대용량 데이터는 빠른 로컬 대칭키로 처리하고 KMS에는 작은 키만 오가므로 네트워크 부하·지연과 KMS 병목이 최소화된다.</p>
<p><strong>키 순환 용이성</strong> — 여기서 순환에는 두 계층이 있고, 둘을 구분하는 것이 중요하다.</p>
<ul>
<li><strong>KEK 순환</strong>: KEK만 새 것으로 바꾸고 저장된 &quot;암호화된 DEK&quot;들만 새 KEK로 재암호화하면 된다. 거대한 실제 데이터는 건드리지 않으므로 거의 즉시 끝난다.</li>
<li><strong>DEK 순환</strong>: DEK를 바꾸는 것은 결국 그 DEK가 암호화한 실제 데이터를 다시 암호화하는 일이므로 여전히 비싸다. 봉투 암호화가 이 비용을 없앤 것이 아니라, <strong>재암호화가 어느 계층에서 일어나는지를 분리</strong>한 것이다.</li>
</ul>
<pre><code>[KEK 순환]  암호화된 DEK만 재암호화        → 작고 빠름 (자주 있을 대응 시나리오)
[DEK 순환]  DEK가 감싼 실제 데이터 재암호화  → 크고 느림 (드물게 발생)</code></pre><p>실무에서는 아예 데이터 1건(또는 1레코드/1파일)마다 새 DEK를 발급해 쓰고 버리는 식으로 <strong>DEK를 잘게 쪼개 재사용하지 않음으로써 &quot;DEK 순환&quot;이라는 개념 자체를 최소화</strong>하는 전략을 흔히 쓴다. 결국 봉투 암호화는 자주 대응해야 할 사고(KEK 의심)를 값싸게 만들고, 드문 사고(DEK 유출)는 발생 확률 자체를 낮추는 설계다.</p>
<p><strong>노출 범위 최소화(blast radius)</strong> — 평문 DEK는 메모리에 잠깐만 존재하므로 훔칠 틈이 좁다. 저장소가 통째로 유출돼도 거기엔 암호문과 암호화된 DEK뿐이라 KEK 없이는 무의미하다. 가장 중요한 KEK 하나에 보호를 집중하면 된다.</p>
<h3 id="평문-dek가-오가는-것은-위험하지-않은가">평문 DEK가 오가는 것은 위험하지 않은가</h3>
<p>봉투 암호화에서 KMS가 평문 DEK를 애플리케이션에 내려주는 순간이 존재하는 것은 사실이다. 다만 이는 &quot;위험 0&quot;이 아니라 <strong>관리 가능한 수준으로 축소된 위험</strong>이다. ① KMS 호출은 TLS 위에서 이뤄져 전송 중 도청으로는 평문 DEK가 드러나지 않는다. ② 설령 그 순간 평문 DEK를 탈취당해도 그 DEK가 암호화한 데이터 일부만 풀릴 뿐, KEK와 다른 DEK는 안전하다(피해가 전체로 번지지 않는다). ③ 평문 DEK는 메모리에만 잠깐 존재하고 즉시 폐기되어 노출 시간이 극히 짧다. ④ DEK 요청 자체가 인증된 클라이언트에게만 허용된다. 평문이 필요 없다면 AWS KMS의 <code>GenerateDataKeyWithoutPlaintext</code>처럼 암호화된 DEK만 받는 방법도 있다. 요컨대 위험을 0으로 만드는 것이 아니라 <strong>감당 가능한 크기로 쪼개는</strong> 보안 설계다.</p>
<h3 id="업계-표준-패턴">업계 표준 패턴</h3>
<p>봉투 암호화는 특정 벤더 기술이 아니라 주요 클라우드의 공통 설계다. <strong>AWS KMS</strong>의 <code>GenerateDataKey</code>가 정확히 이 패턴이며(평문 DEK와 암호화된 DEK를 함께 반환), <strong>AWS EKS</strong>는 쿠버네티스 시크릿을 기본적으로 봉투 암호화로 보호한다. <strong>Google Cloud KMS</strong>도 KEK/DEK 계층과 논리적 데이터 분리를 동일하게 사용한다. <strong>NHN Cloud SKM</strong> 역시 대칭 키의 로컬 키 생성 기능으로 같은 패턴을 제공한다.</p>
<hr>
<h2 id="5-nhn-cloud-skm은-어떻게-구현했나">5. NHN Cloud SKM은 어떻게 구현했나</h2>
<blockquote>
<p>아래 내용은 NHN Cloud 공식 문서(SKM 개요·콘솔·API 가이드) 기준이다. 제품 스펙은 갱신될 수 있으므로 발표 직전 최신 문서로 대조한다.</p>
</blockquote>
<h3 id="3단-키-계층">3단 키 계층</h3>
<p>SKM은 키를 3단으로 겹쳐 보호한다. 상위 키가 하위 것을 암호화하는 구조다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/08faa391-b48e-4b7b-888d-ebdefa59550c/image.png" alt=""></p>
<p>루트 키는 시스템 키를, 시스템 키는 사용자 데이터를 보호한다. 시스템 키는 루트 키로 암호화된 상태로 별도의 시스템 키 관리 서버에 저장되며, SKM 서버는 서비스 시작 시 인증을 거쳐 암호화된 시스템 키를 받아와 루트 키로 복호화해야 비로소 사용할 수 있다. SKM에서 생성된 모든 사용자 데이터는 시스템 키로 암호화되어 저장된다. 4장의 봉투 암호화를 제품 전체 구조로 확장한 형태다.</p>
<h3 id="kek는-결국-어디에-저장되는가--hsm">KEK는 결국 어디에 저장되는가 — HSM</h3>
<p>&quot;KEK를 어디 두는가&quot;라는 질문은 재귀적이다. 시스템 키(KEK 역할)도 루트 키로 암호화되어 저장되고, 루트 키를 지키는 상위 키를 계속 따라가면 소프트웨어로는 무한 반복된다. 이 사슬은 <strong>HSM(Hardware Security Module)</strong> 이라는 전용 물리 장비에서 끊긴다. HSM은 키를 하드웨어 칩 내부에서만 생성·보관·연산하며 키가 평문으로 밖으로 나오지 않는다(암호화 연산만 요청하고 결과만 받는다). 물리적 변조를 감지하면 키를 자동 파기하기도 한다. SKM의 &quot;물리적으로 분리된 시스템&quot;도 이러한 하드웨어/전용 인프라를 기반으로 한다.</p>
<h3 id="물리적-분리">물리적 분리</h3>
<p>SKM에 저장한 사용자 데이터를 비정상적으로 얻으려면 <strong>물리적으로 분리된 세 개의 시스템에서 루트 키·시스템 키·사용자 데이터를 모두</strong> 획득해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/68f1106f-0089-4092-887d-7ae6c14400c4/image.png" alt=""></p>
<p>하나만 뚫려서는 아무것도 얻지 못하므로 공격 난이도가 곱셈으로 증가한다. 4장의 노출 범위 최소화를 인프라 레벨로 구현한 것이다.</p>
<h3 id="클라이언트-인증">클라이언트 인증</h3>
<p>SKM은 저장 안전성과 별개로 접근 통제를 위해 세 가지 인증을 제공한다.</p>
<table>
<thead>
<tr>
<th>인증 방법</th>
<th>확인 대상</th>
</tr>
</thead>
<tbody><tr>
<td>IPv4 주소 인증</td>
<td>클라이언트의 IPv4 주소</td>
</tr>
<tr>
<td>MAC 주소 인증</td>
<td>클라이언트의 MAC 주소(헤더 <code>X-TOAST-CLIENT-MAC-ADDR</code>)</td>
</tr>
<tr>
<td>클라이언트 인증서 인증</td>
<td>통신에 사용하는 클라이언트 인증서</td>
</tr>
</tbody></table>
<p>최소 한 개 이상을 선택해야 하며, 두 개 이상 선택하면 <strong>모두 통과(AND 조건)</strong> 해야 한다. IP 스푸핑이 우려되면 인증서 인증을 조합해 하나의 위조만으로는 통과할 수 없게 만든다. 즉 DB 접속정보 같은 값을 SKM에 넣어두면 정해진 우리 서버에서만 꺼낼 수 있다.</p>
<h3 id="키-3종류와-api">키 3종류와 API</h3>
<p><strong>기밀 데이터</strong>는 32KB 이하 텍스트를 안전 보관하며(대표 용도 DB 접속정보·앱키) 조회 API를 제공한다. <strong>대칭 키</strong>(AES-256, 공공기관용 ARIA-256)는 암호화·복호화 API와 조회 API, 그리고 봉투 암호화용 <strong>로컬 키 생성(create-local-key)</strong> API를 제공한다. <strong>비대칭 키</strong>는 서명·검증 API를 제공한다(서명 대상 245Byte 이하). 이 밖에 호출 클라이언트가 어떻게 인식되는지 확인하는 클라이언트 정보 조회 API가 있어 인증 디버깅에 유용하다.</p>
<p><code>create-local-key</code>가 곧 SKM식 봉투 암호화다. 응답의 <code>localKeyPlaintext</code>는 Base64 디코딩 후 바로 데이터 암호화에 쓰는 <strong>평문 DEK</strong>이고, <code>localKeyCiphertext</code>는 그 로컬키를 SKM 대칭키(KEK 역할)로 암호화한 값으로 <strong>스토리지에 저장할 암호화된 DEK</strong>다. 복호화가 필요하면 저장한 <code>localKeyCiphertext</code>를 복호화 API로 풀어 다시 로컬키를 얻는다.</p>
<table>
<thead>
<tr>
<th>봉투 암호화 용어</th>
<th>SKM 대응</th>
</tr>
</thead>
<tbody><tr>
<td>DEK(평문)</td>
<td><code>localKeyPlaintext</code></td>
</tr>
<tr>
<td>암호화된 DEK(저장용)</td>
<td><code>localKeyCiphertext</code></td>
</tr>
<tr>
<td>KEK</td>
<td>SKM에 저장된 사용자 대칭 키</td>
</tr>
</tbody></table>
<p>추가로 알아둘 점: 상용 엔드포인트는 <code>api-keymanager.cloud.toast.com</code>, 공공기관용은 <code>api-keymanager.gov-nhncloudservice.com</code>이며 앱키(appkey) 기반 경로를 쓴다. 공공기관용은 국정원 검증 알고리즘(ARIA, SEED, RSA-PSS)을 탑재한다. 키 순환(회전)을 제공하지만 &quot;키 가져오기(import)&quot;로 넣은 키는 회전되지 않는다. 또한 승인 기능(approval)으로 승인자·요청자 역할을 분리해 ISMS-P·ISO 등 인증 심사의 키 관리 요구사항과 직무 분리를 충족한다.</p>
<hr>
<h2 id="6-실무-적용-관점">6. 실무 적용 관점</h2>
<h3 id="키-순환-시나리오--kek-유출-의심-시">키 순환 시나리오 — KEK 유출 의심 시</h3>
<p>봉투 암호화와 SKM 구조 덕분에 대응이 &quot;전체 데이터 재암호화&quot;가 아니라 &quot;키 계층만 손보기&quot;로 축소된다.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/b62b1614-0236-4408-abc7-ab6db2adc8e7/image.png" alt=""></p>
<p>핵심은 네 가지다. 버전을 올려 회전하고(SKM은 키에 버전을 매긴다), 봉투 구조라 재암호화 대상은 작은 &quot;암호화된 DEK&quot;뿐이며 실제 데이터 본문은 건드리지 않는다. <strong>구 버전 키를 성급히 삭제하지 않는다</strong> — 예컨대 로그 암호화에 쓰던 대칭키를 회전한 뒤 이전 버전을 바로 삭제하면 그 키로 암호화된 로그를 영영 복호화할 수 없으므로, 구 데이터가 모두 새 키로 이전됐는지 확인 후 폐기한다. 마지막으로 SKM이 키 값을 감추고 API로만 다루므로 애플리케이션은 재배포 없이 키가 교체된다.</p>
<hr>
<h2 id="7-정리">7. 정리</h2>
<h3 id="핵심-3줄-요약">핵심 3줄 요약</h3>
<ol>
<li>데이터는 언젠가 유출된다. 그래서 암호화로 &quot;유출돼도 무의미하게&quot; 만들되, &quot;키를 어디 두나&quot;라는 새 문제가 생긴다.</li>
<li>이 문제는 봉투 암호화(DEK로 데이터, KEK로 DEK)와 전용 키 관리 시스템으로 푼다 — 성능·키순환·노출최소화를 동시에 잡는 업계 표준이다.</li>
<li>NHN Cloud SKM은 이를 3단 키 계층 + 물리적 분리 + 클라이언트 인증(IP/MAC/인증서)으로 구현했고, 실무 가치는 &quot;키를 코드에서 분리하고 재배포 없이 순환&quot;하는 데 있다.</li>
</ol>
<p>한 문장으로: <strong>키를 코드에서 떼어내고, 접근을 통제하고, 언제든 갈아끼울 수 있게 만드는 것.</strong></p>
<h3 id="ax-팀-관점-적용">AX 팀 관점 적용</h3>
<p>AI/데이터 파이프라인에서 모델 API 키, 외부 LLM·벤더 토큰, 데이터소스 접속정보가 코드·노트북·환경변수에 흩어져 있지 않은지 점검하고 기밀 데이터로 통합 관리할 수 있다. 개인정보가 포함된 데이터셋의 민감 필드는 봉투 암호화로 저장해 유출 시 원본 노출을 막는다. 데이터·모델을 다루는 사람과 키를 관리·승인하는 사람을 분리(승인 기능)해 컴플라이언스에 대응하고, 실험 재현을 위해 비밀을 평문 공유하려는 유혹을 SKM 조회로 대체해 재현성과 보안을 함께 챙긴다. 다만 부팅 시 SKM 의존이 서비스 장애로 전이될 수 있으므로 캐싱·재시도 전략을 팀 표준으로 둔다.</p>
<hr>
<p><em>본 문서의 제품 스펙(엔드포인트·알고리즘·API명)은 발표 직전 NHN Cloud 공식 문서로 재확인할 것.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NHN] SKM(Secure Key Manager) 발표 준비 학습 노트 3]]></title>
            <link>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8-3</link>
            <guid>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8-3</guid>
            <pubDate>Thu, 23 Jul 2026 02:18:01 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/74214b0d-40ec-4ed1-8214-a521b33632de/image.png" alt=""></p>
<h1 id="05-nhn-cloud-skm은-어떻게-구현했나">05. NHN Cloud SKM은 어떻게 구현했나</h1>
<blockquote>
<p>이 섹션의 사실들은 NHN Cloud 공식 문서(Secure Key Manager 개요/콘솔/API 가이드) 기준입니다.
발표 전 최신 문서로 한 번 더 대조하면 안전합니다. (제품 스펙은 갱신될 수 있음)</p>
</blockquote>
<p>앞의 이론(3·4번)이 &quot;실제 제품에서 어떻게 구현됐나&quot;로 착지하는 섹션. 청중이 제일 궁금해하는 부분이니 시간 배분 넉넉히.</p>
<hr>
<h2 id="1-3단-키-계층-root-key-→-system-key-→-user-data">1) 3단 키 계층 (Root Key → System Key → User Data)</h2>
<p>SKM은 키를 <strong>3단으로 겹쳐 보호</strong>합니다. 위 키가 아래 것을 감싸는 구조.</p>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/6e058a3f-7dd0-4b59-ac8e-a59661010f1e/image.png" alt=""></p>
<p>동작 방식:</p>
<ul>
<li><strong>루트 키</strong>는 <strong>시스템 키</strong>를 보호(암호화)하는 데 쓰임.</li>
<li><strong>시스템 키</strong>는 <strong>사용자 데이터</strong>를 보호(암호화)하는 데 쓰임.</li>
<li>시스템 키는 루트 키로 암호화된 상태로 <strong>별도의 &quot;시스템 키 관리 서버&quot;</strong> 에 저장됨.</li>
<li>SKM 서버는 <strong>서비스 시작 시 인증 과정을 거쳐</strong> 시스템 키 관리 서버로부터 <strong>암호화된 시스템 키</strong>를 받아오고, <strong>루트 키로 복호화</strong>해야 비로소 시스템 키를 쓸 수 있는 상태가 됨.</li>
<li>SKM에서 생성된 <strong>모든 사용자 데이터는 시스템 키로 암호화</strong>되어 사용자 데이터 저장소에 저장됨.</li>
</ul>
<p>이게 바로 4번의 봉투 암호화 개념을 <strong>제품 전체 구조로 확장</strong>한 것 (KEK 위에 또 KEK를 얹은 다층 구조).</p>
<hr>
<h2 id="2-물리적-분리--세-개를-다-뚫어야-데이터를-얻는다">2) 물리적 분리 — &quot;세 개를 다 뚫어야 데이터를 얻는다&quot;</h2>
<p>핵심 방어 논리이자 발표에서 가장 임팩트 있는 포인트:</p>
<blockquote>
<p>SKM에 저장한 사용자 데이터를 <strong>비정상적인 방법으로</strong> 얻으려면,
<strong>물리적으로 분리된 세 개의 시스템</strong>에서
<strong>루트 키 · 시스템 키 · 사용자 데이터</strong>를 <strong>모두</strong> 획득해야 한다.</p>
</blockquote>
<pre><code class="language-mermaid">flowchart LR
    subgraph S1[&quot;시스템 A (물리적 분리)&quot;]
      RK[&quot;루트 키&quot;]
    end
    subgraph S2[&quot;시스템 B (물리적 분리)&quot;]
      SK[&quot;암호화된 시스템 키&quot;]
    end
    subgraph S3[&quot;시스템 C (물리적 분리)&quot;]
      UD[&quot;암호화된 사용자 데이터&quot;]
    end
    RK -. &quot;셋 다 있어야&lt;br/&gt;복원 가능&quot; .- SK
    SK -. .- UD</code></pre>
<p>왜 강력한가:</p>
<ul>
<li>하나만 뚫려서는 <strong>아무것도 못 얻음.</strong> (예: 사용자 데이터 저장소만 유출돼도 암호화돼 있고, 풀 시스템 키도 없음)</li>
<li>공격자가 <strong>세 시스템을 동시에</strong> 뚫어야 하므로 난이도가 곱셈으로 증가.</li>
<li>4번에서 말한 &quot;노출 범위 최소화(blast radius)&quot;를 인프라 레벨로 구현한 셈.</li>
</ul>
<blockquote>
<p>발표 멘트: &quot;자물쇠 하나가 아니라, <strong>물리적으로 떨어진 방 세 개</strong>에 열쇠를 나눠 둔 겁니다. 방 하나 털어봐야 소용없어요.&quot;</p>
</blockquote>
<hr>
<h2 id="3-클라이언트-인증--누가-꺼내-쓰는지-통제">3) 클라이언트 인증 — 누가 꺼내 쓰는지 통제</h2>
<p>2번에서 강조한 &quot;저장 안전성 ≠ 접근 통제&quot;의 실제 구현. SKM은 <strong>세 가지 인증 방법</strong>을 제공합니다:</p>
<table>
<thead>
<tr>
<th>인증 방법</th>
<th>무엇을 확인하나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>IPv4 주소 인증</strong></td>
<td>요청 클라이언트의 IPv4 주소</td>
</tr>
<tr>
<td><strong>MAC 주소 인증</strong></td>
<td>요청 클라이언트의 MAC 주소 (HTTP 헤더 <code>X-TOAST-CLIENT-MAC-ADDR</code>로 전달)</td>
</tr>
<tr>
<td><strong>클라이언트 인증서 인증</strong></td>
<td>통신에 사용하는 클라이언트 인증서</td>
</tr>
</tbody></table>
<p>규칙 (발표에서 꼭 짚기):</p>
<ul>
<li><strong>최소 한 개 이상</strong>의 인증 방법을 반드시 선택해야 함.</li>
<li><strong>두 개 이상 선택하면 → 전부 통과해야</strong> 데이터 접근 가능 (AND 조건).</li>
<li>즉 &quot;IP도 맞고 + 인증서도 맞아야&quot; 처럼 겹겹이 조합 가능 → 자격증명 하나 탈취로는 부족하게 만듦.</li>
</ul>
<blockquote>
<p>이게 왜 중요? DB 접속정보 같은 걸 SKM에 넣어두면, <strong>정해진 우리 서버(특정 IP/MAC/인증서)에서만</strong> 꺼낼 수 있음. 공격자가 다른 데서 API를 때려도 인증에서 걸림.</p>
</blockquote>
<hr>
<h2 id="4-키-3종류와-각각의-api">4) 키 3종류와 각각의 API</h2>
<p>3번에서 본 데이터 3종에 대응하는 API 목록입니다. <strong>&quot;키 값을 직접 주지 않고, SKM에게 작업을 시킨다&quot;</strong> 는 원칙이 핵심.</p>
<h3 id="a-기밀-데이터-confidential-data">(A) 기밀 데이터 (Confidential Data)</h3>
<ul>
<li>32KB 이하 텍스트를 안전 보관. 대표 용도: <strong>DB 접속정보, 앱키</strong> 등.</li>
<li>API: <strong>기밀 데이터 조회</strong> (인증 통과한 클라이언트만 조회 가능)</li>
</ul>
<h3 id="b-대칭-키-symmetric-key--암복호화">(B) 대칭 키 (Symmetric Key) — 암/복호화</h3>
<ul>
<li>저장한 대칭키(AES-256, 공공기관용은 ARIA-256)로 데이터를 암·복호화.</li>
<li>대칭키 값 자체는 <strong>클라이언트에 직접 노출되지 않고 API로만 사용</strong>.</li>
<li>주요 API:<ul>
<li><strong>암호화</strong>: 32KB 이하 텍스트를 보내면 암호문(+keyVersion) 반환</li>
<li><strong>복호화</strong>: 암호문을 보내면 평문 반환</li>
<li><strong>로컬 키 생성 (create-local-key)</strong>: 4번 봉투 암호화용! 아래 참고</li>
<li><strong>대칭키 조회</strong>: 저장한 대칭키 조회</li>
</ul>
</li>
</ul>
<h4 id="create-local-key--skm식-봉투-암호화-중요">create-local-key = SKM식 봉투 암호화 (중요)</h4>
<p><code>create-local-key</code> 응답에는 두 개가 옵니다:</p>
<ul>
<li><code>localKeyPlaintext</code>: 생성된 로컬 대칭키를 Base64 인코딩한 값. <strong>디코딩 후 바로 데이터 암호화에 사용</strong> → 이게 <strong>DEK(평문)</strong>.</li>
<li><code>localKeyCiphertext</code>: 그 로컬키를 <strong>SKM에 저장된 대칭키(=KEK 역할)로 암호화</strong>한 값. <strong>스토리지에 저장</strong> → 이게 <strong>암호화된 DEK</strong>.</li>
</ul>
<p>나중에 복호화가 필요하면 저장해둔 <code>localKeyCiphertext</code>를 <strong>복호화 API로 풀어서</strong> 다시 로컬키를 얻어 사용.
→ 정확히 4번의 DEK/KEK 봉투 패턴. (이름만 &quot;로컬 키&quot;)</p>
<table>
<thead>
<tr>
<th>봉투 암호화 일반 용어</th>
<th>SKM 대응</th>
</tr>
</thead>
<tbody><tr>
<td>DEK (평문)</td>
<td><code>localKeyPlaintext</code></td>
</tr>
<tr>
<td>암호화된 DEK (저장용)</td>
<td><code>localKeyCiphertext</code></td>
</tr>
<tr>
<td>KEK</td>
<td>SKM에 저장된 사용자 대칭 키</td>
</tr>
</tbody></table>
<h3 id="c-비대칭-키-asymmetric-key--서명검증">(C) 비대칭 키 (Asymmetric Key) — 서명/검증</h3>
<ul>
<li>저장한 비대칭키로 데이터 서명·검증 (위·변조 확인).</li>
<li>서명 대상 데이터는 245 Byte 이하 텍스트.</li>
<li>주요 API: <strong>서명</strong>, <strong>검증</strong></li>
</ul>
<h3 id="공통-클라이언트-정보-조회-api">(공통) 클라이언트 정보 조회 API</h3>
<ul>
<li>API를 호출한 클라이언트가 SKM에 어떻게 인식되는지(IP, MAC 헤더, 인증서 사용 여부 등) 확인용. 인증 디버깅에 유용.</li>
</ul>
<hr>
<h2 id="5-알아두면-발표가-단단해지는-추가-사실">5) 알아두면 발표가 단단해지는 추가 사실</h2>
<ul>
<li><strong>엔드포인트</strong>: 일반 상용은 <code>api-keymanager.cloud.toast.com</code>, 공공기관용은 <code>api-keymanager.gov-nhncloudservice.com</code>. 접근은 <strong>앱키(appkey)</strong> 기반 경로.</li>
<li><strong>공공기관용</strong>은 국정원 검증 알고리즘(<strong>ARIA, SEED, RSA-PSS</strong>) 모듈 탑재 → 공공 프로젝트 대응.</li>
<li><strong>키 순환(회전)</strong> 기능 제공. 단, <strong>&quot;키 가져오기(import)&quot;로 넣은 키는 회전 불가</strong> — SKM에서 생성한 키만 회전됨. (실무 주의점, 6번에서 이어짐)</li>
<li><strong>승인 기능(approval)</strong>: ISMS-P, ISO 등 인증 심사의 키 관리 요구사항 충족용. 승인자/요청자 역할을 나눠 키 저장소 변경에 승인 절차를 강제 → <strong>직무 분리(separation of duties)</strong>.</li>
</ul>
<hr>
<h2 id="요약-체크">요약 체크</h2>
<ul>
<li><input disabled="" type="checkbox"> 루트키→시스템키→사용자데이터 3단 계층을 그릴 수 있다.</li>
<li><input disabled="" type="checkbox"> &quot;물리적 분리 세 시스템&quot; 방어 논리를 한 문장으로 말할 수 있다.</li>
<li><input disabled="" type="checkbox"> IPv4/MAC/인증서 인증과 &quot;복수 선택 시 AND&quot; 규칙을 안다.</li>
<li><input disabled="" type="checkbox"> create-local-key가 봉투 암호화의 DEK/KEK와 어떻게 대응하는지 매핑할 수 있다.</li>
<li><input disabled="" type="checkbox"> 기밀데이터=조회 / 대칭키=암복호화 / 비대칭키=서명검증 을 즉답할 수 있다.</li>
</ul>
<h1 id="06-실무-적용-관점">06. 실무 적용 관점</h1>
<p>이론과 제품 구조를 &quot;실제로 어떻게 쓰나&quot;로 연결하는 섹션. 인턴 발표에서 <strong>본인 경험담</strong>을 얹을 수 있는 파트라 가장 설득력이 큽니다.</p>
<hr>
<h2 id="1-키-순환-시나리오--kek-유출-의심-시-대응-흐름">1) 키 순환 시나리오 — KEK 유출 의심 시 대응 흐름</h2>
<p>발표에서 &quot;그래서 진짜 문제가 터지면 어떻게 하나?&quot;에 답하는 부분. 2번(왜 어려운가)과 4번(봉투 암호화 이점)을 실제 상황에 적용해봅니다.</p>
<h3 id="상황-시스템-키상위-키kek-역할가-유출된-것으로-의심됨">상황: 시스템 키/상위 키(KEK 역할)가 유출된 것으로 의심됨</h3>
<p>봉투 암호화 + SKM 구조 덕분에 대응이 <strong>&quot;전체 데이터 재암호화&quot;가 아니라 &quot;키 계층만 손보기&quot;</strong> 로 축소됩니다.</p>
<pre><code class="language-mermaid">flowchart TD
    A[&quot;🚨 KEK 유출 의심 탐지&quot;] --&gt; B[&quot;새 키 버전 생성 (키 회전)&lt;br/&gt;기존 v1 → 신규 v2&quot;]
    B --&gt; C[&quot;신규 데이터는 v2로 암호화 시작&quot;]
    C --&gt; D[&quot;저장된 &#39;암호화된 DEK&#39;들을&lt;br/&gt;v2로 재암호화 (데이터 본문은 그대로!)&quot;]
    D --&gt; E[&quot;v1 키는 즉시 삭제하지 않고&lt;br/&gt;유예 기간 유지 (구 데이터 복호화용)&quot;]
    E --&gt; F[&quot;모든 데이터가 v2 기준으로 정리되면&lt;br/&gt;v1 폐기&quot;]</code></pre>
<p>핵심 원칙:</p>
<ol>
<li><strong>버전을 올린다 (rotate).</strong> SKM은 키에 버전(v1, v2…)을 매기므로 새 버전을 만들면 됨.</li>
<li><strong>실제 데이터 본문은 안 건드린다.</strong> 봉투 구조라 재암호화 대상은 &quot;암호화된 DEK(작은 것)&quot;뿐. → 2번에서 말한 &quot;수억 건 재암호화&quot; 악몽을 피함.</li>
<li><strong>구 버전 키를 성급히 지우지 않는다.</strong> ⚠️ 공식 문서도 경고: 예를 들어 <strong>로그 암호화에 쓰던 대칭키를 회전한 뒤 이전 버전을 바로 삭제하면, 그 키로 암호화된 로그를 영영 복호화할 수 없다.</strong> 반드시 &quot;구 데이터가 모두 새 키로 이전됐는지&quot; 확인 후 폐기.</li>
<li><strong>클라이언트 코드는 그대로.</strong> SKM이 키 값을 감추고 API로만 다루므로, 애플리케이션은 배포 없이 키 교체가 됨. (2번 딜레마의 실전 해법)</li>
</ol>
<blockquote>
<p>발표 멘트: &quot;봉투 암호화가 없다면 키 유출 = 전체 데이터 재암호화라는 지옥입니다. 있으면 &#39;작은 열쇠들만 다시 잠그면&#39; 되죠. 그래서 이 구조가 <strong>사고 대응 속도</strong>를 결정합니다.&quot;</p>
</blockquote>
<hr>
<h2 id="2-실제-spring-연동-모습--인턴-초기-프로젝트-관점">2) 실제 Spring 연동 모습 — 인턴 초기 프로젝트 관점</h2>
<blockquote>
<p>발표자 상황: &quot;초기 온보딩 프로젝트에 SKM이 붙어 있던 걸로 기억&quot; → 그 경험을 이 구조에 얹으면 됨.
아래는 <strong>개념 흐름</strong>을 보여주는 예시입니다. 실제 연동 방식(SDK/직접 REST/DBCP 연동)은 프로젝트마다 다르니, 발표 전 본인 프로젝트 코드에서 확인해 정확히 맞추세요.</p>
</blockquote>
<h3 id="대표-활용-패턴-a-db-접속정보를-기밀-데이터로-관리">대표 활용 패턴 A: DB 접속정보를 &quot;기밀 데이터&quot;로 관리</h3>
<p>가장 흔하고, 인트로(&quot;키를 어디 두지?&quot;)와 완벽히 연결되는 패턴입니다.</p>
<p><strong>Before (2번에서 깐 나쁜 방법):</strong></p>
<pre><code class="language-yaml"># application.yml — DB 비밀번호가 평문으로 노출 😱
spring:
  datasource:
    username: appuser
    password: P@ssw0rd!   # Git에 올라가고, 서버 침투 시 그대로 읽힘</code></pre>
<p><strong>After (SKM 기밀 데이터로 분리):</strong></p>
<pre><code>앱 부팅 → (인증 통과한 서버가) SKM 기밀 데이터 조회 API 호출
        → DB 접속정보(비번 등)를 런타임에 받아옴
        → DataSource 구성에 주입</code></pre><p>효과:</p>
<ul>
<li>설정 파일/Git에 <strong>비밀번호가 존재하지 않음.</strong></li>
<li>정해진 IP/MAC/인증서를 가진 <strong>우리 서버에서만</strong> 꺼낼 수 있음 (5번 인증).</li>
<li>비번을 바꿔도 SKM 콘솔에서 값만 바꾸면 됨 → 재배포 최소화.</li>
</ul>
<p>개념 코드 (직접 REST 호출 예시, 실제 인증 헤더/경로는 프로젝트 설정에 맞춰야 함):</p>
<pre><code class="language-java">// ⚠️ 개념 예시일 뿐 — 실제 appkey/keyid/인증 방식은 본인 프로젝트 값으로
@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource dataSource(SkmClient skm) {
        // SKM에서 DB 접속정보(기밀 데이터)를 런타임에 조회
        String dbPassword = skm.getConfidential(&quot;db-password-keyid&quot;);

        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(&quot;jdbc:mysql://.../mydb&quot;);
        ds.setUsername(&quot;appuser&quot;);
        ds.setPassword(dbPassword);   // 파일이 아니라 SKM에서 온 값
        return ds;
    }
}</code></pre>
<h3 id="대표-활용-패턴-b-민감-컬럼을-봉투-암호화로-저장">대표 활용 패턴 B: 민감 컬럼을 봉투 암호화로 저장</h3>
<p>주민번호·전화번호 같은 컬럼을 암호화 저장할 때 5번의 <code>create-local-key</code>(=DEK) 활용:</p>
<pre><code class="language-java">// 개념 흐름
// 1) 저장 시
LocalKey dek = skm.createLocalKey(&quot;sym-keyid&quot;);       // 평문DEK + 암호화된DEK
byte[] cipher = AES.encrypt(dek.plaintext(), rawSsn); // 로컬에서 빠르게 암호화
repo.save(cipher, dek.ciphertext());                  // 암호문 + 암호화된DEK 저장
// dek.plaintext() 는 사용 직후 메모리에서 폐기

// 2) 조회 시
byte[] plainDek = skm.decrypt(&quot;sym-keyid&quot;, row.dekCiphertext()); // 암호화된DEK 복호화
String ssn = AES.decrypt(plainDek, row.cipher());                // 로컬에서 복호화</code></pre>
<blockquote>
<p>발표에서: 패턴 A는 &quot;키/비밀을 코드에서 분리&quot;(2번의 해답), 패턴 B는 &quot;봉투 암호화 실전&quot;(4번의 해답). 두 개가 각각 앞 이론을 회수합니다.</p>
</blockquote>
<h3 id="연동-시-실무-체크리스트">연동 시 실무 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 이 서버의 IP/MAC/인증서가 SKM 인증 정책에 등록돼 있나? (안 되면 401/403)</li>
<li><input disabled="" type="checkbox"> appkey / keyid를 코드에 하드코딩하지 않았나? (이것도 설정 분리 대상)</li>
<li><input disabled="" type="checkbox"> SKM 호출 실패 시 <strong>fallback/재시도/캐싱</strong> 전략이 있나? (부팅 시 SKM 죽어 있으면 앱도 못 뜸 → 가용성 고려)</li>
<li><input disabled="" type="checkbox"> 평문 DEK를 로그·예외 메시지에 찍고 있지 않나? (제일 흔한 실수)</li>
<li><input disabled="" type="checkbox"> 키 회전 시 구 버전 유예 기간을 지켰나? (1번 경고)</li>
</ul>
<hr>
<h2 id="요약-체크-1">요약 체크</h2>
<ul>
<li><input disabled="" type="checkbox"> KEK 유출 의심 시 대응 4단계를 순서대로 말할 수 있다.</li>
<li><input disabled="" type="checkbox"> &quot;구 키를 성급히 지우면 안 되는&quot; 이유를 예시로 설명할 수 있다.</li>
<li><input disabled="" type="checkbox"> Spring에서 DB 접속정보를 SKM 기밀데이터로 빼는 흐름을 그릴 수 있다.</li>
<li><input disabled="" type="checkbox"> 본인 초기 프로젝트에서 SKM이 A/B 중 어떤 패턴으로 쓰였는지 확인했다.</li>
</ul>
<h1 id="07-정리--qa">07. 정리 &amp; Q&amp;A</h1>
<p>발표를 닫는 섹션. 핵심을 압축하고, 예상 질문을 미리 방어하고, &quot;우리 팀엔 어떻게 적용?&quot;으로 마무리.</p>
<hr>
<h2 id="1-핵심-3줄-요약">1) 핵심 3줄 요약</h2>
<blockquote>
<ol>
<li>데이터는 언젠가 유출된다. 그래서 <strong>암호화</strong>로 &quot;유출돼도 무의미하게&quot; 만든다 — 대신 <strong>&quot;키를 어디 두나&quot;</strong> 라는 새 문제가 생긴다.</li>
<li>이 문제는 <strong>봉투 암호화(DEK로 데이터, KEK로 DEK)</strong> 와 <strong>전용 키 관리 시스템</strong>으로 푼다 — 성능·키순환·노출최소화를 동시에 잡는 업계 표준.</li>
<li><strong>NHN Cloud SKM</strong>은 이를 <strong>3단 키 계층 + 물리적 분리 + 클라이언트 인증(IP/MAC/인증서)</strong> 로 구현했고, 실무에선 <strong>키를 코드에서 분리하고 재배포 없이 순환</strong>하는 게 핵심 가치다.</li>
</ol>
</blockquote>
<p>한 문장으로 더 줄이면:</p>
<blockquote>
<p><strong>&quot;키를 코드에서 떼어내고, 접근을 통제하고, 언제든 갈아끼울 수 있게 만드는 것.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="2-ax-팀-관점에서-적용해본다면">2) AX 팀 관점에서 적용해본다면?</h2>
<p>(발표 마지막을 &quot;그래서 우리에게 의미&quot;로 닫으면 인상이 좋습니다. 아래는 논의 씨앗 — 팀 상황에 맞게 취사선택)</p>
<ul>
<li><strong>AI/데이터 파이프라인의 비밀 관리</strong>: 모델 API 키, 외부 LLM/벤더 토큰, 데이터소스 접속정보 등이 코드·노트북·환경변수에 흩어져 있진 않은지 → 기밀 데이터로 통합 관리.</li>
<li><strong>학습/추론 데이터의 민감 필드</strong>: 개인정보가 포함된 데이터셋 컬럼을 봉투 암호화로 저장하면, 유출 시에도 원본 노출을 막음.</li>
<li><strong>직무 분리(승인 기능)</strong>: 데이터·모델을 만지는 사람과 키를 관리·승인하는 사람을 분리 → 컴플라이언스(ISMS-P 등) 대응.</li>
<li><strong>재현성 vs 보안 균형</strong>: 실험 재현을 위해 비밀을 평문으로 공유하려는 유혹이 큰데, SKM 조회로 대체하면 재현성과 보안을 둘 다 챙김.</li>
<li><strong>가용성 고려</strong>: AI 서비스가 부팅 때 SKM에 의존하면, SKM 장애가 서비스 장애로 전이될 수 있음 → 캐싱/재시도 전략을 팀 표준으로.</li>
</ul>
<blockquote>
<p>닫는 멘트 예시: &quot;결국 SKM은 특정 제품이라기보다 <strong>&#39;비밀을 다루는 규율&#39;</strong> 입니다. AX 팀도 늘어나는 API 키와 민감 데이터를 이 규율로 관리하면, 사고가 나도 피해를 최소화할 수 있습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="3-예상-qa-질문-방어">3) 예상 Q&amp;A (질문 방어)</h2>
<p><strong>Q. SKM(KMS) 자체가 뚫리면 다 끝 아닌가요?</strong>
A. 그래서 SKM은 단일 지점이 아니라 <strong>물리적으로 분리된 3개 시스템(루트키/시스템키/사용자데이터)</strong> 으로 나눠 하나만 뚫려선 복원이 안 되게 설계돼 있습니다. 또 KEK는 시스템 밖으로 나오지 않고, 접근은 IP/MAC/인증서 인증으로 통제됩니다. &quot;완전 무적&quot;은 아니지만 공격 난이도를 곱셈으로 올리는 구조입니다.</p>
<p><strong>Q. 봉투 암호화 안 쓰고 그냥 KMS로 직접 암호화하면 안 되나요?</strong>
A. 가능하지만 대용량에서 무너집니다. KMS 직접 암호화는 보통 작은 크기 제한(예: AWS는 4KB)이 있고, 매번 데이터를 네트워크로 왕복시키면 느리고 KMS가 병목이 됩니다. 봉투 방식은 무거운 암호화를 로컬에서(빠른 대칭키) 하고, 작은 DEK만 KMS로 보냅니다.</p>
<p><strong>Q. 키를 바꾸면 기존 데이터는 다 복호화 못 하나요?</strong>
A. 아닙니다. SKM은 키에 <strong>버전</strong>을 매기고, 봉투 구조라 재암호화 대상은 실제 데이터가 아니라 <strong>작은 &quot;암호화된 DEK&quot;</strong> 뿐입니다. 구 버전 키는 유예 기간 동안 남겨 구 데이터를 복호화하다가, 이전이 끝나면 폐기합니다. (성급히 지우면 구 데이터 복호화 불가라 주의)</p>
<p><strong>Q. 대칭키가 더 빠르면 비대칭키는 왜 필요하죠?</strong>
A. 용도가 다릅니다. 대칭키는 <strong>데이터 암·복호화(속도)</strong>, 비대칭키는 <strong>서명·검증(위·변조 확인, 부인 방지)</strong> 에 씁니다. SKM도 이 둘을 데이터 타입으로 분리해 둡니다.</p>
<p><strong>Q. IP 인증이면 IP 스푸핑으로 우회되지 않나요?</strong>
A. 그래서 SKM은 인증 방법을 <strong>조합(AND)</strong> 할 수 있습니다. IP + MAC + 클라이언트 인증서를 함께 걸면 하나만 위조해선 통과 못 합니다. 특히 인증서 기반은 스푸핑이 훨씬 어렵습니다.</p>
<p><strong>Q. 애플리케이션이 부팅 때 SKM에 의존하면 SKM 장애 = 서비스 장애 아닌가요?</strong>
A. 맞는 리스크입니다. 그래서 실무에선 조회 결과 캐싱, 재시도/타임아웃, 부분 축약 기동 같은 가용성 전략을 함께 설계합니다. 보안과 가용성의 트레이드오프를 팀 정책으로 정해두는 게 좋습니다.</p>
<p><strong>Q. 왜 굳이 유료 관리형 SKM을 쓰나요? 직접 만들면?</strong>
A. 물리적 분리, 인증 심사(ISMS-P/ISO) 대응, 감사 로그, 키 순환·승인 워크플로를 직접 안정적으로 구현·운영하는 비용이 큽니다. 관리형은 이 &quot;규율과 인프라&quot;를 검증된 형태로 제공합니다.</p>
<hr>
<h2 id="4-발표-직전-최종-점검">4) 발표 직전 최종 점검</h2>
<ul>
<li><input disabled="" type="checkbox"> 봉투 암호화 다이어그램을 말 없이 화이트보드에 그릴 수 있다.</li>
<li><input disabled="" type="checkbox"> SKM 3단 계층 + 3종 데이터/API를 30초 안에 요약할 수 있다.</li>
<li><input disabled="" type="checkbox"> 내 프로젝트에서 SKM이 실제로 어떻게 쓰였는지 한 사례를 말할 수 있다.</li>
<li><input disabled="" type="checkbox"> 위 Q&amp;A 중 최소 4개는 안 보고 답할 수 있다.</li>
<li><input disabled="" type="checkbox"> 제품 스펙(엔드포인트/알고리즘/API명)을 최신 공식 문서로 한 번 대조했다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NHN] SKM(Secure Key Manager) 발표 준비 학습 노트 2]]></title>
            <link>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8-2</link>
            <guid>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8-2</guid>
            <pubDate>Thu, 23 Jul 2026 02:16:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/fd4c94f3-aec7-4b55-baf5-02568b942c74/image.png" alt=""></p>
<h1 id="03-암호화-기초">03. 암호화 기초</h1>
<p>봉투 암호화(4번)와 SKM(5번)을 이해하려면 딱 이만큼의 암호학 기초가 필요합니다. 깊게 안 들어가고 &quot;왜 이 두 종류를 나눠 쓰는가&quot;에 집중.</p>
<hr>
<h2 id="1-대칭키-symmetric-key--aes-aria-등">1) 대칭키 (Symmetric Key) — AES, ARIA 등</h2>
<p><strong>하나의 같은 키</strong>로 암호화도 하고 복호화도 함.</p>
<pre><code>평문 ──[ 키 K 로 암호화 ]──► 암호문
암호문 ──[ 같은 키 K 로 복호화 ]──► 평문</code></pre><p>특징:</p>
<ul>
<li><strong>빠르다.</strong> 연산이 가벼워서 대용량 데이터 암/복호화에 적합.</li>
<li>그래서 <strong>실제 데이터(수백 MB, DB 컬럼 수억 건)</strong> 를 암호화할 땐 거의 항상 대칭키.</li>
<li>대표 알고리즘: <strong>AES</strong>(국제 표준, AES-256이 사실상 기본), <strong>ARIA/SEED</strong>(국내 국정원 검증 알고리즘, 공공기관용).</li>
<li>약점: <strong>키 하나를 주고받는 게 어렵다.</strong> 상대에게 키를 안전하게 전달하는 것 자체가 문제(→ 비대칭키가 이걸 보완).</li>
</ul>
<blockquote>
<p>한 줄: &quot;빠르지만 키를 나눠 갖는 게 골칫거리.&quot;</p>
</blockquote>
<hr>
<h2 id="2-비대칭키-asymmetric-key--rsa-등">2) 비대칭키 (Asymmetric Key) — RSA 등</h2>
<p><strong>한 쌍의 키(공개키 + 개인키)</strong> 를 사용. 한쪽으로 잠그면 반드시 다른 쪽으로만 풀림.</p>
<pre><code>공개키(Public)  : 누구에게나 공개해도 됨
개인키(Private) : 절대 노출 X, 소유자만 보관</code></pre><p>두 가지 대표 용도:</p>
<ol>
<li><strong>암호화 용도</strong>: 공개키로 암호화 → 개인키로만 복호화. (상대에게 키를 미리 안 나눠줘도 됨 → 대칭키의 전달 문제 해결)</li>
<li><strong>서명/검증 용도</strong> (SKM에서 쓰는 방식):<ul>
<li>개인키로 데이터에 <strong>서명</strong> → 공개키로 <strong>검증</strong>.</li>
<li>&quot;이 데이터가 정말 그 사람이 보냈고, 중간에 위·변조되지 않았다&quot;를 증명.</li>
</ul>
</li>
</ol>
<p>특징:</p>
<ul>
<li><strong>느리다.</strong> 연산이 무거워서 대용량 데이터 자체를 암호화하는 데는 부적합.</li>
<li>그래서 실무에선 <strong>작은 데이터의 서명/검증</strong>이나, <strong>대칭키를 안전하게 주고받는 용도</strong>로 씀.</li>
</ul>
<blockquote>
<p>한 줄: &quot;느리지만 키를 미리 안 나눠도 되고, &#39;누가 보냈는지&#39;를 증명할 수 있다.&quot;</p>
</blockquote>
<hr>
<h2 id="3-그래서-실무는-둘을-섞어-쓴다-하이브리드">3) 그래서 실무는 둘을 섞어 쓴다 (하이브리드)</h2>
<ul>
<li><strong>데이터 암호화</strong> = 대칭키 (빠름)</li>
<li><strong>그 대칭키를 보호/전달</strong> = 비대칭키 or 상위 대칭키</li>
</ul>
<p>이 조합의 정점이 다음 장의 <strong>봉투 암호화</strong>입니다. 여기까지 오면 4번이 자연스럽게 연결됩니다.</p>
<hr>
<h2 id="4-skm은-데이터를-왜-3종류로-나눴나--용도별-분리">4) SKM은 데이터를 왜 3종류로 나눴나 — 용도별 분리</h2>
<p>NHN Cloud SKM은 보관하는 데이터를 <strong>기밀 데이터 / 대칭 키 / 비대칭 키</strong> 세 가지로 구분합니다. 각각 &quot;무엇을 하고 싶은가&quot;에 대응합니다.</p>
<table>
<thead>
<tr>
<th>SKM 데이터 유형</th>
<th>무엇인가</th>
<th>대표 용도</th>
<th>제공 API</th>
</tr>
</thead>
<tbody><tr>
<td><strong>기밀 데이터</strong> (Confidential Data)</td>
<td>임의의 민감 텍스트 (32KB 이하)</td>
<td>DB 접속정보, API 앱키 등 &quot;그냥 안전하게 보관하고 꺼내 쓰고 싶은 값&quot;</td>
<td><strong>조회</strong></td>
</tr>
<tr>
<td><strong>대칭 키</strong> (Symmetric Key)</td>
<td>AES-256(공공기관용은 ARIA-256) 키</td>
<td>데이터 <strong>암호화 / 복호화</strong></td>
<td>암호화 / 복호화 / (봉투용) 로컬키 생성 / 조회</td>
</tr>
<tr>
<td><strong>비대칭 키</strong> (Asymmetric Key)</td>
<td>RSA 계열 키 쌍</td>
<td>데이터 <strong>서명 / 검증</strong> (위·변조 확인)</td>
<td>서명 / 검증</td>
</tr>
</tbody></table>
<p>이렇게 나눈 이유를 발표에서 이렇게 설명하면 깔끔합니다:</p>
<blockquote>
<p>&quot;사용자가 원하는 건 결국 세 가지입니다.
① 값을 <strong>그냥 안전하게 보관</strong>하고 싶다 → 기밀 데이터.
② 데이터를 <strong>암·복호화</strong>하고 싶다 → 대칭 키.
③ 데이터가 <strong>위·변조 안 됐는지 검증</strong>하고 싶다 → 비대칭 키.
SKM은 이 세 가지 니즈에 맞춰 데이터 타입과 API를 분리해 둔 겁니다.&quot;</p>
</blockquote>
<p>중요 포인트 (5번의 복선):</p>
<ul>
<li>대칭 키·비대칭 키는 <strong>키 값 자체가 클라이언트로 직접 노출되지 않고, 오직 API를 통해서만 사용</strong>됩니다.
즉 &quot;키를 받아와서 내가 쓴다&quot;가 아니라 &quot;SKM에게 암호화/복호화/서명을 시킨다&quot;에 가까움.</li>
<li>덕분에 키가 외부로 새지 않고, <strong>키 순환도 클라이언트 코드 변경 없이</strong> 가능. (2번에서 말한 딜레마의 해법!)</li>
</ul>
<hr>
<h2 id="요약-체크">요약 체크</h2>
<ul>
<li><input disabled="" type="checkbox"> 대칭 vs 비대칭을 &quot;속도&quot;와 &quot;용도&quot; 두 축으로 설명할 수 있다.</li>
<li><input disabled="" type="checkbox"> 비대칭키의 서명/검증이 &quot;위·변조 확인&quot;임을 안다.</li>
<li><input disabled="" type="checkbox"> SKM의 3종 데이터가 각각 어떤 사용자 니즈에 대응하는지 매핑할 수 있다.</li>
</ul>
<h1 id="04-봉투-암호화-envelope-encryption">04. 봉투 암호화 (Envelope Encryption)</h1>
<p>발표의 하이라이트. 다이어그램 한 장으로 승부하세요. 이 개념만 청중이 이해하면 5·6번은 술술 풀립니다.</p>
<hr>
<h2 id="1-왜-필요한가--매번-kms로-보내면-느리다">1) 왜 필요한가 — &quot;매번 KMS로 보내면 느리다&quot;</h2>
<p>가장 순진한 발상: *&quot;데이터를 통째로 KMS에 보내서 암호화해 달라고 하자.&quot;*</p>
<p>문제:</p>
<ul>
<li>KMS/HSM 같은 키 관리 시스템은 보통 <strong>작은 데이터만 직접 암호화</strong>하도록 설계됨 (예: AWS KMS는 직접 암호화가 4KB까지).</li>
<li>수백 MB 파일이나 대용량 컬럼을 <strong>매번 네트워크로 KMS에 왕복</strong>시키면 → 느리고, 대역폭 낭비, 지연 폭증.</li>
<li>게다가 KMS가 단일 병목이 됨.</li>
</ul>
<p><strong>봉투 암호화</strong>는 이걸 이렇게 뒤집습니다:</p>
<blockquote>
<p>&quot;데이터는 내 서버에서 (빠른) <strong>로컬 키(DEK)</strong> 로 직접 암호화하자.
그리고 그 <strong>DEK만</strong> KMS의 상위 키(KEK)로 암호화해서 같이 보관하자.&quot;
→ 네트워크로는 <strong>작은 키만</strong> 오가고, 무거운 데이터 암호화는 로컬에서 처리.</p>
</blockquote>
<hr>
<h2 id="2-핵심-두-키-dek와-kek">2) 핵심 두 키: DEK와 KEK</h2>
<table>
<thead>
<tr>
<th>약어</th>
<th>이름</th>
<th>역할</th>
<th>어디에 있나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>DEK</strong></td>
<td>Data Encryption Key</td>
<td><strong>실제 데이터</strong>를 암호화하는 키</td>
<td>애플리케이션(로컬)에서 잠깐 사용 후 메모리에서 폐기</td>
</tr>
<tr>
<td><strong>KEK</strong></td>
<td>Key Encryption Key</td>
<td><strong>DEK를 암호화(보호)</strong>하는 키</td>
<td>KMS/SKM 안에서만 존재, <strong>절대 밖으로 안 나옴</strong></td>
</tr>
</tbody></table>
<p>한 문장 정리:</p>
<blockquote>
<p><strong>&quot;DEK로 데이터를 잠그고, 그 DEK를 다시 KEK로 잠근다.&quot;</strong> — 봉투(편지)를 봉투(더 큰 봉투)에 넣는 느낌이라 &#39;봉투&#39; 암호화.</p>
</blockquote>
<p>핵심 규칙:</p>
<ul>
<li><strong>평문 DEK는 저장하지 않는다.</strong> 쓰고 나면 메모리에서 즉시 폐기.</li>
<li>저장하는 건 <strong>암호화된 DEK(encrypted DEK)</strong> 뿐. 이건 데이터 옆에 같이 저장해도 안전(KEK 없이는 못 품).</li>
<li><strong>KEK는 KMS 밖으로 절대 안 나온다.</strong> DEK 복호화는 항상 KMS에게 시킨다.</li>
</ul>
<hr>
<h2 id="3-암호화-플로우">3) 암호화 플로우</h2>
<pre><code class="language-mermaid">flowchart TD
    A[&quot;애플리케이션: 데이터 D 를 저장하려 함&quot;] --&gt; B[&quot;KMS/SKM에 &#39;DEK 하나 만들어줘&#39; 요청&quot;]
    B --&gt; C[&quot;KMS: DEK 생성 후 두 형태로 반환&lt;br/&gt;① 평문 DEK  ② KEK로 암호화된 DEK&quot;]
    C --&gt; D[&quot;① 평문 DEK로 데이터 D를 로컬에서 암호화 → 암호문 C&quot;]
    D --&gt; E[&quot;평문 DEK는 메모리에서 즉시 폐기 🔥&quot;]
    E --&gt; F[&quot;저장소에 함께 저장:&lt;br/&gt;암호문 C + 암호화된 DEK&quot;]</code></pre>
<p>말로 풀면:</p>
<ol>
<li>데이터를 저장할 일이 생김.</li>
<li>KMS/SKM에 &quot;데이터 키(DEK) 하나 발급해줘&quot; 요청.</li>
<li>KMS는 <strong>평문 DEK</strong>(지금 쓸 것)와 <strong>암호화된 DEK</strong>(저장할 것)를 함께 돌려줌.</li>
<li>평문 DEK로 <strong>로컬에서</strong> 데이터를 빠르게 암호화.</li>
<li>평문 DEK는 <strong>즉시 폐기</strong>. (메모리에만 잠깐 있었음)</li>
<li>저장소에는 <strong>암호문 + 암호화된 DEK</strong>를 나란히 저장.</li>
</ol>
<hr>
<h2 id="4-복호화-플로우">4) 복호화 플로우</h2>
<pre><code class="language-mermaid">flowchart TD
    A[&quot;저장소에서 읽기:&lt;br/&gt;암호문 C + 암호화된 DEK&quot;] --&gt; B[&quot;KMS/SKM에 &#39;이 암호화된 DEK 풀어줘&#39; 요청&quot;]
    B --&gt; C[&quot;KMS: KEK로 DEK 복호화 → 평문 DEK 반환&quot;]
    C --&gt; D[&quot;평문 DEK로 암호문 C를 로컬에서 복호화 → 데이터 D&quot;]
    D --&gt; E[&quot;평문 DEK는 다시 즉시 폐기 🔥&quot;]</code></pre>
<p>말로 풀면:</p>
<ol>
<li>저장소에서 <strong>암호문 + 암호화된 DEK</strong>를 함께 읽음.</li>
<li>KMS/SKM에 &quot;이 암호화된 DEK 좀 풀어줘&quot; 요청 (여기만 네트워크 왕복, 작은 데이터).</li>
<li>KMS가 내부 KEK로 DEK를 복호화해서 <strong>평문 DEK</strong>를 돌려줌.</li>
<li>그 평문 DEK로 <strong>로컬에서</strong> 암호문을 복호화.</li>
<li>평문 DEK 즉시 폐기.</li>
</ol>
<blockquote>
<p>발표 포인트: <strong>네트워크를 타는 건 항상 &quot;작은 DEK&quot;뿐, 무거운 데이터는 로컬에서 처리.</strong> 이 한 문장이 봉투 암호화의 전부입니다.</p>
</blockquote>
<hr>
<h2 id="5-이-구조의-이점-발표-핵심-3가지">5) 이 구조의 이점 (발표 핵심 3가지)</h2>
<h3 id="①-성능">① 성능</h3>
<ul>
<li>대용량 데이터는 빠른 로컬 대칭키(DEK)로 처리.</li>
<li>KMS에는 작은 키만 오감 → 네트워크 부하·지연 최소화, KMS 병목 해소.</li>
</ul>
<h3 id="②-키-순환-용이성-2번에서-말한-딜레마의-진짜-해법">② 키 순환 용이성 (2번에서 말한 딜레마의 진짜 해법)</h3>
<ul>
<li>키를 바꾼다 = <strong>KEK만 새 걸로 바꾸면 됨.</strong></li>
<li>그럼 저장돼 있는 &quot;암호화된 DEK&quot;들만 <strong>새 KEK로 다시 암호화</strong>하면 끝.</li>
<li><strong>거대한 실제 데이터는 하나도 안 건드려도 됨!</strong>
(수억 건 데이터 재암호화 → 작은 DEK 몇 개만 재암호화로 축소)</li>
</ul>
<pre><code>[순진한 재암호화]   전체 데이터 복호화 → 재암호화 (수 시간~며칠)
[봉투 암호화 순환]  암호화된 DEK 들만 KEK로 재암호화 (거의 즉시)</code></pre><ul>
<li>DEK는 키 순환을 하지 않는 것인가?</li>
</ul>
<blockquote>
<p>정리하면 &quot;DEK도 순환할 수 있지만, KEK 순환과는 별개의 문제이고 목적도 다르다&quot; 입니다. 이 둘을 구분 못 하면 헷갈리기 딱 좋은 지점이에요.
두 가지 순환은 서로 다른 질문에 답합니다
<img src="https://velog.velcdn.com/images/gitit-sehyeon/post/dfea632e-abbf-45b8-8f15-859a81b893d4/image.png" alt="">
즉 제가 전에 &quot;봉투 암호화 순환은 거의 즉시&quot;라고 한 건 KEK 순환의 얘기였고, DEK 자체를 바꾸는 건 결국 다시 &quot;실제 데이터 재암호화&quot; 문제로 돌아갑니다. DEK가 이 딜레마를 완전히 없앤 게 아니라, &quot;어느 계층에서 순환이 발생하느냐&quot;를 옮겨놓은 것이에요.</p>
</blockquote>
<h3 id="③-노출-범위-최소화-blast-radius-축소">③ 노출 범위 최소화 (blast radius 축소)</h3>
<ul>
<li>평문 DEK는 메모리에 잠깐만 존재 → 훔칠 틈이 좁음.</li>
<li>저장소가 통째로 유출돼도 거기엔 <strong>암호문 + 암호화된 DEK</strong>뿐 → KEK 없이는 무의미.</li>
<li>KEK는 KMS 밖으로 안 나오므로 <strong>가장 중요한 키 하나만 집중 보호</strong>하면 됨.</li>
</ul>
<hr>
<h2 id="6-이게-업계-표준-패턴임을-보여주기-사례">6) 이게 업계 표준 패턴임을 보여주기 (사례)</h2>
<p>&quot;우리만의 특이한 방식이 아니라, 주요 클라우드가 다 쓰는 정석&quot;이라고 말할 수 있는 근거들:</p>
<ul>
<li><strong>AWS KMS</strong>: <code>GenerateDataKey</code> API가 정확히 이 패턴 — 평문 DEK와 암호화된 DEK를 함께 돌려주고, 개발자는 평문 DEK로 로컬 암호화 후 폐기, 암호화된 DEK만 저장. KMS 직접 암호화는 4KB 한계라 대용량은 봉투 방식 권장.</li>
<li><strong>AWS EKS</strong>: 쿠버네티스 시크릿(etcd)을 기본적으로 봉투 암호화로 보호 — DEK로 리소스 암호화, KEK(KMS)로 DEK 보호하는 <strong>다중 방어(defense-in-depth)</strong> 로 설명됨.</li>
<li><strong>Google Cloud KMS</strong>: 저장 데이터 암호화에 KEK/DEK 계층과 &quot;논리적 데이터 분리&quot;를 동일하게 사용.</li>
<li><strong>NHN Cloud SKM</strong>: 대칭 키 API에 <strong>로컬 키 생성(create-local-key)</strong> 기능을 제공 — 평문 로컬키(바로 사용)와 SKM 대칭키로 암호화된 로컬키(저장용)를 함께 반환. 이름만 다를 뿐 정확히 DEK/KEK 봉투 패턴. (자세한 건 05번)</li>
</ul>
<blockquote>
<p>발표 멘트:
&quot;AWS, GCP, 그리고 우리가 쓰는 NHN Cloud까지 — 이름(Data Key, 로컬 키)만 다를 뿐 <strong>똑같은 봉투 암호화 패턴</strong>을 씁니다. 즉 이건 특정 벤더 기술이 아니라 <strong>업계 표준 설계</strong>입니다.&quot;</p>
</blockquote>
<hr>
<h2 id="요약-체크-1">요약 체크</h2>
<ul>
<li><input disabled="" type="checkbox"> DEK와 KEK의 역할을 한 문장씩 구분해서 말할 수 있다.</li>
<li><input disabled="" type="checkbox"> 암호화·복호화 플로우를 화이트보드에 그릴 수 있다.</li>
<li><input disabled="" type="checkbox"> &quot;성능 / 키 순환 / 노출 최소화&quot; 3대 이점을 각각 근거와 함께 설명할 수 있다.</li>
<li><input disabled="" type="checkbox"> 봉투 암호화가 업계 표준임을 사례 2개 이상으로 말할 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NHN] SKM(Secure Key Manager) 발표 준비 학습 노트 1]]></title>
            <link>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8</link>
            <guid>https://velog.io/@gitit-sehyeon/NHN-SKMSecure-Key-Manager-%EB%B0%9C%ED%91%9C-%EC%A4%80%EB%B9%84-%ED%95%99%EC%8A%B5-%EB%85%B8%ED%8A%B8</guid>
            <pubDate>Thu, 23 Jul 2026 02:14:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/1dc4b5e9-8912-468c-afe0-c0272b771efc/image.png" alt=""></p>
<blockquote>
<p>AX개발팀 인턴 정세현 — 민감 데이터 관리 리서치
&quot;먼저 내가 이해하기 위한&quot; 공부용 노트입니다. 발표 대본이 아니라, 각 개념을 스스로 설명할 수 있게 되는 걸 목표로 씀.</p>
</blockquote>
<hr>
<h2 id="파일-구성-발표-목차와-11-매핑">파일 구성 (발표 목차와 1:1 매핑)</h2>
<table>
<thead>
<tr>
<th>파일</th>
<th>발표 섹션</th>
<th>한 줄 목표</th>
</tr>
</thead>
<tbody><tr>
<td><code>인트로</code></td>
<td>1. 인트로</td>
<td>&quot;왜 이 얘기를 하는지&quot; 청중을 설득</td>
</tr>
<tr>
<td><code>키관리_난제</code></td>
<td>2. 왜 키 관리가 어려운가</td>
<td>순진한 방법들이 왜 다 깨지는지</td>
</tr>
<tr>
<td><code>암호화_기초</code></td>
<td>3. 암호화 기초</td>
<td>대칭/비대칭, SKM 데이터 3종</td>
</tr>
<tr>
<td><code>봉투암호화</code></td>
<td>4. 봉투 암호화</td>
<td>DEK/KEK 구조와 그 이점</td>
</tr>
<tr>
<td><code>NHN_SKM</code></td>
<td>5. NHN Cloud SKM 구현</td>
<td>3단 키 계층·인증·API</td>
</tr>
<tr>
<td><code>실무적용</code></td>
<td>6. 실무 적용 관점</td>
<td>키 순환 + Spring 연동</td>
</tr>
<tr>
<td><code>정리_QnA</code></td>
<td>7. 정리 &amp; Q&amp;A</td>
<td>3줄 요약 + 예상 질문 방어</td>
</tr>
</tbody></table>
<hr>
<h2 id="이-발표의-큰-스토리라인-한-문장씩">이 발표의 큰 스토리라인 (한 문장씩)</h2>
<ol>
<li>데이터를 평문으로 두면 DB 한 번 뚫리면 끝이다 → <strong>암호화하자.</strong></li>
<li>암호화하면 이제 <strong>&quot;키를 어디 두지?&quot;</strong> 라는 새 문제가 생긴다.</li>
<li>키를 그냥 코드/파일/환경변수에 두는 건 다 한계가 있다 → <strong>전용 키 관리 시스템(KMS)</strong> 이 필요.</li>
<li>대량 데이터를 매번 KMS로 보내 암호화하면 느리다 → <strong>봉투 암호화(DEK/KEK)</strong> 로 푼다.</li>
<li>NHN Cloud SKM은 이걸 <strong>3단 키 계층 + 물리적 분리 + 클라이언트 인증</strong>으로 구현했다.</li>
<li>실무에선 <strong>키 순환</strong>과 <strong>Spring 연동</strong>이 핵심 접점이다.</li>
<li>결국 <strong>&quot;키를 코드에서 분리하고, 접근을 통제하고, 갈아끼울 수 있게&quot;</strong> 만드는 게 핵심.</li>
</ol>
<hr>
<h2 id="핵심-용어집-발표-전-이-정도는-입에-붙이기">핵심 용어집 (발표 전 이 정도는 입에 붙이기)</h2>
<table>
<thead>
<tr>
<th>용어</th>
<th>풀이</th>
<th>한 줄 설명</th>
</tr>
</thead>
<tbody><tr>
<td>평문 (Plaintext)</td>
<td>-</td>
<td>암호화 안 된 원본 데이터</td>
</tr>
<tr>
<td>암호문 (Ciphertext)</td>
<td>-</td>
<td>암호화된 데이터</td>
</tr>
<tr>
<td>대칭키</td>
<td>Symmetric Key</td>
<td>암호화·복호화에 <strong>같은</strong> 키 사용 (AES, ARIA)</td>
</tr>
<tr>
<td>비대칭키</td>
<td>Asymmetric Key</td>
<td>공개키/개인키 <strong>쌍</strong> 사용 (RSA)</td>
</tr>
<tr>
<td>KMS</td>
<td>Key Management Service</td>
<td>키 생성·보관·접근제어를 전담하는 시스템</td>
</tr>
<tr>
<td>DEK</td>
<td>Data Encryption Key</td>
<td>실제 데이터를 암호화하는 키</td>
</tr>
<tr>
<td>KEK</td>
<td>Key Encryption Key</td>
<td>DEK를 암호화(보호)하는 키</td>
</tr>
<tr>
<td>봉투 암호화</td>
<td>Envelope Encryption</td>
<td>DEK로 데이터 암호화 → KEK로 DEK 암호화</td>
</tr>
<tr>
<td>키 순환</td>
<td>Key Rotation</td>
<td>키를 주기적으로 새 버전으로 교체</td>
</tr>
<tr>
<td>HSM</td>
<td>Hardware Security Module</td>
<td>키를 하드웨어 안에서만 다루는 전용 장비</td>
</tr>
<tr>
<td>기밀 데이터</td>
<td>Confidential Data</td>
<td>SKM이 보관하는 임의의 민감 텍스트(예: DB 접속정보)</td>
</tr>
</tbody></table>
<hr>
<h2 id="발표-시-밸런스">발표 시 밸런스</h2>
<ul>
<li>이론(3·4번)에서 너무 오래 끌면 지루해집니다. <strong>봉투 암호화 다이어그램 1장</strong>으로 승부.</li>
<li>진짜 청중이 궁금해하는 건 5·6번 (<strong>&quot;우리 것은 어떻게 돌아가냐&quot;</strong>). 여기 시간 배분을 넉넉히.</li>
<li>인턴 발표의 장점: &quot;제가 초기 프로젝트에서 실제로 SKM을 붙여봤다&quot;는 경험담이 슬라이드 10장보다 강함.</li>
</ul>
<h1 id="01-인트로--왜-키-관리-이야기를-하는가">01. 인트로 — 왜 키 관리 이야기를 하는가</h1>
<h2 id="1-문제-제기-데이터베이스는-언젠가-뚫린다">1) 문제 제기: 데이터베이스는 언젠가 뚫린다</h2>
<p>발표의 출발점은 겁을 주는 게 아니라 <strong>&quot;어차피 뚫린다고 가정하면 그 다음이 갈린다&quot;</strong> 는 관점입니다.</p>
<ul>
<li>방화벽, WAF, 접근제어 다 해도 유출 사고는 계속 일어납니다. (SQL Injection, 내부자, 백업 파일 방치, 클라우드 버킷 오설정, 탈취된 관리자 계정 등)</li>
<li>그래서 보안 업계의 전제는 <strong>&quot;침해는 불가피하다(assume breach)&quot;</strong> 로 이동했습니다.</li>
<li>핵심 질문이 바뀝니다: *&quot;어떻게 안 뚫리게 하지?&quot;* → *&quot;뚫려도 데이터가 안 새게 하려면?&quot;*</li>
</ul>
<blockquote>
<p>발표 멘트 예시:
&quot;완벽하게 막는 건 불가능합니다. 그래서 질문을 바꿔야 합니다. <strong>DB가 통째로 유출돼도, 그 안의 데이터가 쓸모없는 상태로 있게 하려면?</strong> 그 답이 암호화이고, 오늘 이야기의 시작점입니다.&quot;</p>
</blockquote>
<h3 id="사례를-언급할-때-주의-발표자-노트">사례를 언급할 때 주의 (발표자 노트)</h3>
<p>구체적인 사고명·피해 규모 숫자는 틀리면 신뢰가 깨지므로, 확실한 것만 말하세요.
안전하게 쓸 수 있는 사실 두 가지:</p>
<ul>
<li>과거 대형 유출 사고들에서 <strong>비밀번호가 평문 또는 약한 방식으로 저장</strong>돼 있어 피해가 커진 경우가 많았다. (일반적으로 알려진 사실)</li>
<li>국내 <strong>개인정보보호법</strong>은 비밀번호·고유식별정보(주민번호 등) 같은 민감 항목의 <strong>암호화 저장을 의무화</strong>한다. → &quot;법적으로도 암호화는 선택이 아니라 필수&quot;라는 근거로 활용.</li>
</ul>
<p>숫자·회사명을 꼭 넣고 싶으면 발표 전에 1차 출처(뉴스·보안기관 보고서)로 재확인하고 넣으세요.</p>
<hr>
<h2 id="2-암호화가-안-되어-있으면--평문-그대로-노출">2) 암호화가 안 되어 있으면 = 평문 그대로 노출</h2>
<p>간단하지만 강력한 대비 그림입니다.</p>
<pre><code>[암호화 X]  DB 유출  →  이름: 홍길동 / 주민번호: 900101-1... / 비밀번호: password123
                        → 그대로 읽힘. 끝.

[암호화 O]  DB 유출  →  이름: 8f3a9c... / 주민번호: b21e77... / 비밀번호: 5d41402a...
                        → 키가 없으면 의미 없는 바이트 덩어리.</code></pre><p>포인트: <strong>암호화의 목적은 &quot;유출을 막는 것&quot;이 아니라 &quot;유출됐을 때 무의미하게 만드는 것&quot;.</strong>
즉 최후의 방어선(last line of defense)입니다.</p>
<hr>
<h2 id="3-그러면-암호화-키는-어디에-두지--오늘의-진짜-주제">3) 그러면 암호화 키는 어디에 두지? — 오늘의 진짜 주제</h2>
<p>여기서 자연스럽게 역설이 등장합니다.</p>
<ul>
<li>데이터를 암호화하면 안전해진다.</li>
<li>그런데 그 암호화를 풀 수 있는 <strong>키</strong>가 있어야 정상적으로 서비스가 돌아간다.</li>
<li><strong>키가 데이터 바로 옆에(코드/DB/서버) 같이 있으면?</strong> → 유출 시 키도 같이 털린다 → 암호화한 의미가 사라짐.</li>
</ul>
<blockquote>
<p>발표 전환 멘트:
&quot;암호화는 문제를 푸는 동시에 새 문제를 만듭니다. <strong>자물쇠는 채웠는데, 열쇠를 문 옆 화분 밑에 두면</strong> 아무 소용이 없죠. 그래서 오늘 진짜 주제는 암호화 자체가 아니라 <strong>&#39;키를 어떻게, 어디에 둘 것인가&#39;</strong> 입니다.&quot;</p>
</blockquote>
<p>이 한 문장이 발표 전체를 관통하는 축입니다. 인트로의 마지막은 반드시 이 질문으로 닫으세요.</p>
<hr>
<h2 id="인트로-요약-스스로-설명-체크">인트로 요약 (스스로 설명 체크)</h2>
<ul>
<li><input disabled="" type="checkbox"> &quot;assume breach&quot; 관점을 한 문장으로 말할 수 있다.</li>
<li><input disabled="" type="checkbox"> 암호화의 목적이 &quot;유출 방지&quot;가 아니라 &quot;유출 무력화&quot;임을 설명할 수 있다.</li>
<li><input disabled="" type="checkbox"> 암호화가 곧바로 &quot;키를 어디 두지?&quot;라는 새 문제를 낳는다는 흐름을 만들 수 있다.</li>
</ul>
<h1 id="02-왜-키-관리가-어려운가">02. 왜 키 관리가 어려운가</h1>
<p>인트로에서 던진 질문 &quot;키를 어디 두지?&quot;에 대해, <strong>사람들이 흔히 시도하는 방법들이 왜 하나씩 깨지는지</strong>를 보여주는 섹션입니다. 이 섹션의 목적은 &quot;그래서 전용 시스템(KMS/SKM)이 필요하다&quot;로 자연스럽게 몰아가는 것.</p>
<hr>
<h2 id="1-순진한-저장-방법-3종과-각각의-한계">1) 순진한 저장 방법 3종과 각각의 한계</h2>
<h3 id="a-하드코딩--소스코드에-키를-박아둠">(A) 하드코딩 — 소스코드에 키를 박아둠</h3>
<pre><code class="language-java">// 최악의 예시
String secretKey = &quot;my-super-secret-key-1234&quot;;</code></pre>
<p>한계:</p>
<ul>
<li><strong>소스 저장소(Git)에 그대로 커밋</strong> → 사내 개발자 전원, 협력사, (실수 시) 공개 저장소에 노출.</li>
<li>Git은 <strong>히스토리가 영구 보존</strong> → 나중에 키만 지워도 과거 커밋에 남아있음.</li>
<li>배포된 바이너리/JAR를 디컴파일하면 문자열이 그대로 드러남.</li>
<li>키를 바꾸려면 <strong>코드 수정 → 재빌드 → 재배포</strong>. 사실상 못 바꿈.</li>
</ul>
<h3 id="b-설정-파일--applicationyml-properties-등">(B) 설정 파일 — <code>application.yml</code>, <code>.properties</code> 등</h3>
<pre><code class="language-yaml">db:
  password: &quot;P@ssw0rd!&quot;
crypto:
  key: &quot;base64EncodedKey==&quot;</code></pre>
<p>한계:</p>
<ul>
<li>하드코딩보다 낫지만 <strong>여전히 파일 = 평문</strong>. 서버 침투 시 그냥 읽힘.</li>
<li>설정 파일도 실수로 Git에 올라가는 사고가 매우 흔함.</li>
<li>파일에 접근 가능한 사람 = 키에 접근 가능한 사람. <strong>권한 분리가 안 됨.</strong></li>
</ul>
<h3 id="c-환경변수--export-db_key">(C) 환경변수 — <code>export DB_KEY=...</code></h3>
<pre><code class="language-bash">export CRYPTO_KEY=&quot;base64EncodedKey==&quot;</code></pre>
<p>한계:</p>
<ul>
<li>파일 커밋 사고는 줄지만, <strong>프로세스 환경은 생각보다 잘 샌다</strong>:<ul>
<li><code>/proc/&lt;pid&gt;/environ</code> 로 같은 서버의 다른 프로세스/사용자가 조회 가능.</li>
<li>에러 로그·크래시 덤프·APM 툴이 환경변수를 통째로 찍는 경우.</li>
<li>하위 프로세스에 자동 상속 → 의도치 않은 전파.</li>
</ul>
</li>
<li>컨테이너 오케스트레이션에서 <strong>평문 상태로 기록/노출</strong>되기 쉬움.</li>
</ul>
<blockquote>
<p>정리 멘트:
&quot;세 방법의 공통 문제는 결국 <strong>키가 애플리케이션과 같은 신뢰 경계 안에 평문으로 존재한다</strong>는 겁니다. 앱이 뚫리면 키도 같이 뚫립니다.&quot;</p>
</blockquote>
<hr>
<h2 id="2-안전하게-둬도-남는-문제--인증누가-꺼내-쓰냐">2) &quot;안전하게 둬도&quot; 남는 문제 — 인증(누가 꺼내 쓰냐)</h2>
<p>키를 아주 안전한 금고에 넣었다고 칩시다. 그래도 끝이 아닙니다.</p>
<ul>
<li>금고가 있어도 <strong>아무나 문을 열 수 있으면</strong> 금고가 아닙니다.</li>
<li>즉 &quot;저장의 안전성&quot;과 별개로 <strong>&quot;접근 통제(인증/인가)&quot;</strong> 가 필요합니다.<ul>
<li><em>이 요청이 정말 우리 애플리케이션 서버가 보낸 게 맞나?</em></li>
<li><em>탈취된 자격증명으로 아무 데서나 키를 꺼내가는 건 아닌가?</em></li>
</ul>
</li>
<li>그래서 좋은 키 관리 시스템은 <strong>&quot;키를 안전하게 보관&quot; + &quot;요청자를 확실히 검증&quot;</strong> 을 함께 제공합니다.<ul>
<li>(5번에서 볼 SKM의 IPv4/MAC/인증서 인증이 바로 이 지점입니다.)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-키를-바꿔야-할-때--키-순환rotation과-기존-데이터-문제">3) 키를 바꿔야 할 때 — 키 순환(rotation)과 기존 데이터 문제</h2>
<p>이게 실무에서 진짜 골치 아픈 부분이고, 발표에서 짚으면 &quot;깊이 있다&quot;는 인상을 줍니다.</p>
<p>키는 <strong>언젠가 반드시 바꿔야 합니다.</strong> 왜?</p>
<ul>
<li>키가 유출됐거나 유출이 의심될 때</li>
<li>규정(컴플라이언스)상 주기적 교체가 요구될 때</li>
<li>담당자 퇴사 등 인적 리스크가 생겼을 때</li>
</ul>
<p>그런데 키를 바꾸면 곧바로 딜레마가 생깁니다:</p>
<pre><code>기존 데이터는 &quot;옛날 키&quot;로 암호화돼 있음.
새 데이터는 &quot;새 키&quot;로 암호화하고 싶음.
→ 그럼 옛날 데이터는 새 키로 못 푸는데?</code></pre><p>순진한 해법의 한계:</p>
<ul>
<li><strong>전체 데이터를 옛 키로 복호화 → 새 키로 재암호화</strong>? 데이터가 수억 건이면 몇 시간~며칠, 그 사이 서비스 영향.</li>
<li>키를 바꾸려면 <strong>모든 애플리케이션 서버의 설정을 동시에 교체</strong>해야 함 → 배포 지옥.</li>
</ul>
<p>좋은 KMS는 이 문제를:</p>
<ul>
<li><strong>키에 버전을 매겨서</strong> (v1, v2 …) 옛 데이터는 옛 버전으로, 새 데이터는 새 버전으로 관리하고,</li>
<li>애플리케이션은 <strong>키 값 자체를 몰라도</strong> 되게 만들어(참조만 함) &quot;클라이언트 코드 변경 없이 키만 교체&quot;가 가능하게 합니다.</li>
<li>(뒤에서 볼 <strong>봉투 암호화</strong>가 이 재암호화 비용을 극적으로 줄여줍니다.)</li>
</ul>
<hr>
<h2 id="이-섹션이-만드는-결론-다음-섹션으로의-다리">이 섹션이 만드는 결론 (다음 섹션으로의 다리)</h2>
<blockquote>
<p>하드코딩·설정파일·환경변수 전부 (1) 저장 안전성, (2) 접근 통제, (3) 키 교체
세 가지를 동시에 만족 못 시킨다.
→ <strong>이 세 가지를 전담하는 시스템이 필요하다 = KMS / Secure Key Manager.</strong></p>
</blockquote>
<h2 id="요약-체크">요약 체크</h2>
<ul>
<li><input disabled="" type="checkbox"> 하드코딩/설정파일/환경변수의 한계를 각각 1개씩 즉답할 수 있다.</li>
<li><input disabled="" type="checkbox"> &quot;저장 안전성 ≠ 접근 통제&quot;를 구분해서 설명할 수 있다.</li>
<li><input disabled="" type="checkbox"> 키 순환이 왜 어려운지(기존 데이터 재암호화 문제)를 그림으로 그릴 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Proxy vs Reverse Proxy 완전 정리]]></title>
            <link>https://velog.io/@gitit-sehyeon/Proxy-vs-Reverse-Proxy-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@gitit-sehyeon/Proxy-vs-Reverse-Proxy-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 22 Jul 2026 09:19:47 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/fa76c1df-2606-45f8-a18c-caf69d2f7615/image.png" alt=""></p>
<blockquote>
<p>백엔드 개발자 관점에서 정리한 문서. 개념 → 동작 → 설정 → 트러블슈팅 → 면접 순서.</p>
</blockquote>
<hr>
<h2 id="0-한-줄-요약">0. 한 줄 요약</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>누구를 대신하나</th>
<th>누가 설치하나</th>
<th>상대방이 존재를 아는가</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Forward Proxy</strong></td>
<td><strong>클라이언트</strong>를 대신함</td>
<td>클라이언트 쪽(회사, 개인)</td>
<td>서버는 클라이언트가 누군지 모름</td>
</tr>
<tr>
<td><strong>Reverse Proxy</strong></td>
<td><strong>서버</strong>를 대신함</td>
<td>서버 쪽(서비스 운영자)</td>
<td>클라이언트는 뒤에 뭐가 있는지 모름</td>
</tr>
</tbody></table>
<p><strong>핵심 구분법</strong>: &quot;이 프록시는 누구 편이고, 누가 돈 내고 설치했는가?&quot;
클라이언트 편이면 forward, 서버 편이면 reverse. 끝.</p>
<p><code>proxy</code> 라는 단어 자체가 &quot;대리인&quot;이라는 뜻이다. 대리인이 누구를 대리하느냐의 차이일 뿐이다.</p>
<hr>
<h2 id="1-forward-proxy-정방향-프록시">1. Forward Proxy (정방향 프록시)</h2>
<h3 id="11-위치">1.1 위치</h3>
<pre><code>[클라이언트] → [Forward Proxy] → (인터넷) → [서버]
   내부망          경계             ↑
                                서버가 보는 IP = 프록시 IP</code></pre><h3 id="12-동작">1.2 동작</h3>
<ol>
<li>클라이언트가 <strong>&quot;나는 프록시를 쓰겠다&quot;고 명시적으로 설정</strong>한다.<ul>
<li>브라우저 프록시 설정</li>
<li><code>HTTP_PROXY=http://proxy.company.com:3128</code> 환경변수</li>
<li><code>npm config set proxy</code>, <code>git config --global http.proxy</code></li>
</ul>
</li>
<li>클라이언트는 목적지 서버가 아니라 <strong>프록시에게</strong> 요청을 보낸다.</li>
<li>프록시가 대신 서버에 요청하고, 응답을 클라이언트에게 전달한다.</li>
</ol>
<blockquote>
<p><strong>중요</strong>: forward proxy는 클라이언트가 설정해야 동작한다. 클라이언트가 인지하고 있다는 게 reverse proxy와의 가장 큰 차이다.</p>
</blockquote>
<h3 id="13-https는-어떻게-처리하나">1.3 HTTPS는 어떻게 처리하나</h3>
<p>HTTP는 프록시가 내용을 다 볼 수 있지만, HTTPS는 암호화되어 있다. 그래서 <code>CONNECT</code> 메서드를 쓴다.</p>
<pre><code>CONNECT api.github.com:443 HTTP/1.1
Host: api.github.com</code></pre><p>프록시는 그냥 TCP 터널만 뚫어주고 <strong>암호화된 바이트를 그대로 중계</strong>한다. 내용은 못 본다.</p>
<p>내용까지 보려면 <strong>MITM(중간자) 프록시</strong>를 써야 하는데, 이 경우 프록시가 자체 CA 인증서로 가짜 인증서를 발급한다. 그래서 회사 노트북에 &quot;사내 루트 인증서 설치&quot;를 시키는 것이다. (Charles, Fiddler, mitmproxy 원리도 이것)</p>
<h3 id="14-왜-쓰나">1.4 왜 쓰나</h3>
<table>
<thead>
<tr>
<th>목적</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>접근 제어</strong></td>
<td>사내에서 특정 사이트 차단. 방화벽과 함께 조합</td>
</tr>
<tr>
<td><strong>캐싱</strong></td>
<td>같은 리소스를 여러 직원이 받을 때 대역폭 절약</td>
</tr>
<tr>
<td><strong>익명성</strong></td>
<td>서버가 실제 클라이언트 IP를 모름</td>
</tr>
<tr>
<td><strong>로깅/감사</strong></td>
<td>누가 어디 접속했는지 기록</td>
</tr>
<tr>
<td><strong>IP 우회</strong></td>
<td>지역 제한 우회, 크롤링 시 IP 로테이션</td>
</tr>
<tr>
<td><strong>패키지 미러</strong></td>
<td>사내 Nexus/Artifactory로 npm/maven 요청 프록시</td>
</tr>
</tbody></table>
<h3 id="15-대표-소프트웨어">1.5 대표 소프트웨어</h3>
<ul>
<li><strong>Squid</strong> — 전통적인 캐싱 프록시</li>
<li><strong>mitmproxy</strong> — 디버깅용</li>
<li><strong>Charles / Fiddler</strong> — 앱 개발 시 API 트래픽 확인</li>
<li><strong>Zscaler, Blue Coat</strong> — 기업용 보안 프록시</li>
</ul>
<h3 id="16-vpn과의-차이">1.6 VPN과의 차이</h3>
<table>
<thead>
<tr>
<th></th>
<th>Forward Proxy</th>
<th>VPN</th>
</tr>
</thead>
<tbody><tr>
<td>계층</td>
<td>L7 (주로 HTTP)</td>
<td>L3 (네트워크 전체)</td>
</tr>
<tr>
<td>범위</td>
<td>설정한 앱/프로토콜만</td>
<td>기기의 모든 트래픽</td>
</tr>
<tr>
<td>암호화</td>
<td>프록시 구간 암호화 안 될 수도</td>
<td>터널 전체 암호화</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-reverse-proxy-역방향-프록시">2. Reverse Proxy (역방향 프록시)</h2>
<h3 id="21-위치">2.1 위치</h3>
<pre><code>                                    ┌→ [App Server 1 :8080]
[클라이언트] → (인터넷) → [Reverse Proxy] → [App Server 2 :8080]
                          :80/:443    └→ [App Server 3 :8080]
                              ↑
                    클라이언트는 이게 &quot;그 서버&quot;인 줄 안다</code></pre><p>클라이언트는 <code>https://foliosage.cloud</code> 에 접속했다고 생각한다. 실제로는 Caddy가 받아서 Docker 내부의 <code>backend:8080</code>으로 넘긴 것이지만, 클라이언트는 그걸 알 수 없고 알 필요도 없다.</p>
<h3 id="22-왜-쓰나-이게-진짜-중요">2.2 왜 쓰나 (이게 진짜 중요)</h3>
<h4 id="1-tls-termination">(1) TLS Termination</h4>
<p>HTTPS 복호화를 프록시가 담당한다. 애플리케이션은 평문 HTTP만 처리하면 된다.</p>
<pre><code>클라이언트 --HTTPS--&gt; [Caddy] --HTTP--&gt; Spring Boot :8080</code></pre><p><strong>장점</strong>: 인증서 관리를 한 곳에서만. Spring Boot에 keystore 설정할 필요 없음. 인증서 갱신도 프록시만 재시작.</p>
<h4 id="2-로드-밸런싱">(2) 로드 밸런싱</h4>
<p>여러 인스턴스에 요청 분배. 알고리즘: round-robin, least connections, ip-hash(세션 고정), weighted.</p>
<h4 id="3-포트-은닉--단일-진입점">(3) 포트 은닉 / 단일 진입점</h4>
<p>80/443만 열고 나머지는 다 닫는다. 애플리케이션 포트(8080, 5432 등)는 외부에 노출 안 됨.</p>
<h4 id="4-경로도메인-기반-라우팅">(4) 경로/도메인 기반 라우팅</h4>
<pre><code>/api/*     → Spring Boot 백엔드
/          → Next.js 프론트엔드
admin.x.com → 관리자 서비스</code></pre><p>MSA에서 필수.</p>
<h4 id="5-정적-파일-서빙">(5) 정적 파일 서빙</h4>
<p>이미지, JS, CSS를 애플리케이션까지 안 보내고 프록시가 바로 응답. 훨씬 빠르고 애플리케이션 부하 감소.</p>
<h4 id="6-캐싱--압축">(6) 캐싱 / 압축</h4>
<p>gzip, brotli 압축. 응답 캐싱.</p>
<h4 id="7-보안">(7) 보안</h4>
<ul>
<li>Rate limiting (DDoS 완화)</li>
<li>WAF (SQL injection, XSS 필터링)</li>
<li>요청 크기 제한, 슬로우 로리스 방어</li>
<li>실제 서버 구조를 외부에서 알 수 없음</li>
</ul>
<h4 id="8-무중단-배포">(8) 무중단 배포</h4>
<p>Blue-Green, Canary 배포 시 프록시에서 트래픽을 전환한다.</p>
<h3 id="23-대표-소프트웨어">2.3 대표 소프트웨어</h3>
<table>
<thead>
<tr>
<th>이름</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Nginx</strong></td>
<td>사실상 표준. 설정 세밀, 성능 좋음, 자료 많음</td>
</tr>
<tr>
<td><strong>Caddy</strong></td>
<td>자동 HTTPS(Let&#39;s Encrypt). 설정이 극단적으로 짧음</td>
</tr>
<tr>
<td><strong>Traefik</strong></td>
<td>Docker/K8s 라벨 기반 자동 서비스 디스커버리</td>
</tr>
<tr>
<td><strong>HAProxy</strong></td>
<td>로드밸런싱 특화, L4/L7 모두</td>
</tr>
<tr>
<td><strong>Envoy</strong></td>
<td>Service Mesh(Istio)의 데이터 플레인</td>
</tr>
<tr>
<td><strong>AWS ALB</strong></td>
<td>관리형. 오토스케일링과 통합</td>
</tr>
<tr>
<td><strong>Cloudflare</strong></td>
<td>CDN + 리버스 프록시 + WAF</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-요청-흐름-상세-비교">3. 요청 흐름 상세 비교</h2>
<h3 id="forward-proxy">Forward Proxy</h3>
<pre><code>1. 브라우저: &quot;프록시 설정이 있네&quot; → proxy.corp.com:3128 으로 연결
2. 브라우저 → 프록시: GET http://example.com/page HTTP/1.1   ← 절대 URL!
3. 프록시: 정책 검사(차단 목록?) → 캐시 확인 → 없으면 요청
4. 프록시 → example.com: GET /page HTTP/1.1
5. example.com이 보는 소스 IP = 프록시 IP
6. 응답 역순으로 전달</code></pre><h3 id="reverse-proxy">Reverse Proxy</h3>
<pre><code>1. 브라우저: DNS 조회 foliosage.cloud → 43.201.x.x (프록시 IP)
2. 브라우저 → 프록시: GET /api/users HTTP/1.1   ← 상대 경로
                      Host: foliosage.cloud
3. 프록시: TLS 복호화 → 라우팅 규칙 매칭 → 헤더 추가
4. 프록시 → backend:8080: GET /api/users HTTP/1.1
                          Host: foliosage.cloud
                          X-Forwarded-For: 121.x.x.x   ← 원래 클라이언트 IP
                          X-Forwarded-Proto: https
5. 백엔드 응답 → 프록시 → (압축/캐싱) → 브라우저</code></pre><hr>
<h2 id="4-실전-설정">4. 실전 설정</h2>
<h3 id="41-caddy-가장-간단">4.1 Caddy (가장 간단)</h3>
<pre><code class="language-caddyfile">foliosage.cloud {
    # 인증서 자동 발급/갱신. 이 한 줄로 HTTPS 완료
    reverse_proxy backend:8080
}

api.foliosage.cloud {
    reverse_proxy backend:8080 {
        health_uri /actuator/health
        health_interval 10s
    }
}

# 경로 기반 분기
example.com {
    handle /api/* {
        reverse_proxy backend:8080
    }
    handle {
        reverse_proxy frontend:3000
    }
}</code></pre>
<p>Caddy는 <code>X-Forwarded-For</code>, <code>X-Forwarded-Proto</code>, <code>X-Forwarded-Host</code>를 <strong>자동으로</strong> 붙여준다. Nginx처럼 일일이 안 써도 된다.</p>
<h3 id="42-nginx-세밀한-제어">4.2 Nginx (세밀한 제어)</h3>
<pre><code class="language-nginx">upstream backend {
    least_conn;                    # 연결 수 적은 쪽으로
    server app1:8080 max_fails=3 fail_timeout=30s;
    server app2:8080;
    keepalive 32;                  # 커넥션 재사용
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;   # HTTPS 강제
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 20M;      # 업로드 크기 제한

    location / {
        proxy_pass http://backend;

        # ★ 이 4줄이 핵심. 안 쓰면 백엔드가 진실을 모른다
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
    }

    # 정적 파일은 프록시가 직접 서빙
    location /static/ {
        alias /var/www/static/;
        expires 30d;
        add_header Cache-Control &quot;public, immutable&quot;;
    }

    # WebSocket
    location /ws {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_read_timeout 3600s;
    }

    # ★ SSE (스트리밍) — 버퍼링 끄지 않으면 응답이 안 흘러나온다
    location /api/chat/stream {
        proxy_pass http://backend;
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;
        chunked_transfer_encoding on;
    }
}</code></pre>
<h3 id="43-docker-compose-조합">4.3 Docker Compose 조합</h3>
<pre><code class="language-yaml">services:
  caddy:
    image: caddy:2-alpine
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    depends_on:
      - backend

  backend:
    build: .
    expose:
      - &quot;8080&quot;          # ports가 아니라 expose! 외부 노출 안 됨
    environment:
      SPRING_PROFILES_ACTIVE: prod

  db:
    image: postgres:16
    expose:
      - &quot;5432&quot;          # 외부에서 절대 접근 불가

volumes:
  caddy_data:</code></pre>
<blockquote>
<p><code>ports: &quot;8080:8080&quot;</code> 로 열어두면 리버스 프록시를 둔 의미가 절반 사라진다. 공격자가 <code>http://43.201.x.x:8080</code> 으로 프록시를 우회할 수 있기 때문이다. <code>expose</code>를 쓰거나 <code>127.0.0.1:8080:8080</code> 으로 바인딩하자.</p>
</blockquote>
<hr>
<h2 id="5-forwarded-헤더--리버스-프록시의-최대-함정">5. Forwarded 헤더 — 리버스 프록시의 최대 함정</h2>
<p>리버스 프록시를 두는 순간, 백엔드는 <strong>원래 요청에 대한 진실을 잃는다</strong>.</p>
<table>
<thead>
<tr>
<th>백엔드가 착각하는 것</th>
<th>실제</th>
<th>해결 헤더</th>
</tr>
</thead>
<tbody><tr>
<td>클라이언트 IP = 172.18.0.2 (프록시)</td>
<td>121.x.x.x</td>
<td><code>X-Forwarded-For</code></td>
</tr>
<tr>
<td>프로토콜 = http</td>
<td>https</td>
<td><code>X-Forwarded-Proto</code></td>
</tr>
<tr>
<td>호스트 = backend:8080</td>
<td>foliosage.cloud</td>
<td><code>X-Forwarded-Host</code> / <code>Host</code></td>
</tr>
<tr>
<td>포트 = 8080</td>
<td>443</td>
<td><code>X-Forwarded-Port</code></td>
</tr>
</tbody></table>
<h3 id="51-x-forwarded-for-체인">5.1 X-Forwarded-For 체인</h3>
<p>프록시가 여러 개면 콤마로 누적된다.</p>
<pre><code>X-Forwarded-For: 121.140.1.5, 70.41.3.18, 150.172.238.178
                 ↑ 원래 클라이언트   ↑ 중간 프록시들</code></pre><p><strong>보안 주의</strong>: 클라이언트가 직접 <code>X-Forwarded-For: 1.2.3.4</code> 를 위조해서 보낼 수 있다. 그래서</p>
<ol>
<li>최외곽 프록시는 들어온 XFF를 <strong>덮어써야</strong> 한다 (<code>X-Real-IP $remote_addr</code>)</li>
<li>신뢰하는 프록시 IP 목록(trusted proxies)을 설정하고, 그 뒤에서 오는 값만 믿는다</li>
</ol>
<h3 id="52-rfc-7239-표준-헤더">5.2 RFC 7239 표준 헤더</h3>
<pre><code>Forwarded: for=121.140.1.5; proto=https; host=example.com</code></pre><p>표준이지만 현실에서는 <code>X-Forwarded-*</code> 가 압도적으로 많이 쓰인다.</p>
<h3 id="53-spring-boot-설정">5.3 Spring Boot 설정</h3>
<pre><code class="language-yaml">server:
  forward-headers-strategy: NATIVE   # 또는 FRAMEWORK</code></pre>
<table>
<thead>
<tr>
<th>값</th>
<th>동작</th>
<th>언제</th>
</tr>
</thead>
<tbody><tr>
<td><code>NATIVE</code></td>
<td>Tomcat의 <code>RemoteIpValve</code>가 처리</td>
<td>임베디드 톰캣 사용 시 (일반적)</td>
</tr>
<tr>
<td><code>FRAMEWORK</code></td>
<td>Spring의 <code>ForwardedHeaderFilter</code> 사용</td>
<td>서블릿 컨테이너 무관하게</td>
</tr>
<tr>
<td><code>NONE</code> (기본)</td>
<td>무시</td>
<td>프록시 없을 때</td>
</tr>
</tbody></table>
<p>설정 안 하면 이런 일이 생긴다:</p>
<pre><code class="language-java">request.getRemoteAddr();   // 172.18.0.1 (도커 게이트웨이) ← 로그가 전부 같은 IP
request.getScheme();       // &quot;http&quot; ← Spring Security가 https로 무한 리다이렉트
ServletUriComponentsBuilder.fromCurrentRequest();  // http://backend:8080/... ← 잘못된 링크</code></pre>
<p><strong>신뢰 프록시 제한</strong>:</p>
<pre><code class="language-yaml">server:
  tomcat:
    remoteip:
      remote-ip-header: X-Forwarded-For
      protocol-header: X-Forwarded-Proto
      internal-proxies: &quot;172\\.1[6-9]\\.\\d{1,3}\\.\\d{1,3}&quot;</code></pre>
<hr>
<h2 id="6-reverse-proxy-vs-load-balancer-vs-api-gateway">6. Reverse Proxy vs Load Balancer vs API Gateway</h2>
<p>셋 다 &quot;앞단에 서서 뒤로 넘긴다&quot;는 점은 같다. 관심사의 범위가 다르다.</p>
<pre><code>     범위 좁음 ────────────────────────────────→ 범위 넓음
     L4 LB      Reverse Proxy(L7)     API Gateway</code></pre><table>
<thead>
<tr>
<th></th>
<th>Load Balancer</th>
<th>Reverse Proxy</th>
<th>API Gateway</th>
</tr>
</thead>
<tbody><tr>
<td>계층</td>
<td>L4(TCP) 또는 L7</td>
<td>L7(HTTP)</td>
<td>L7 + 애플리케이션</td>
</tr>
<tr>
<td>주 목적</td>
<td>트래픽 분산</td>
<td>서버 대리·보호</td>
<td>API 관리</td>
</tr>
<tr>
<td>기능</td>
<td>헬스체크, 분산</td>
<td>+ TLS, 캐싱, 라우팅</td>
<td>+ 인증/인가, 레이트리밋, 요청 변환, 응답 집계, API 버저닝, 사용량 과금</td>
</tr>
<tr>
<td>예시</td>
<td>AWS NLB, HAProxy</td>
<td>Nginx, Caddy</td>
<td>Spring Cloud Gateway, Kong, AWS API Gateway</td>
</tr>
</tbody></table>
<p>정리하면:</p>
<ul>
<li><strong>모든 로드밸런서가 리버스 프록시는 아니다</strong> (L4 LB는 HTTP를 이해 못함)</li>
<li><strong>모든 리버스 프록시는 로드밸런싱을 할 수 있다</strong></li>
<li><strong>API Gateway는 리버스 프록시 + 애플리케이션 관심사</strong></li>
</ul>
<p>MSA에서는 보통 이렇게 겹쳐서 쓴다:</p>
<pre><code>[Client] → [ALB] → [Spring Cloud Gateway] → [각 마이크로서비스]
           L7 LB       API Gateway</code></pre><hr>
<h2 id="7-자주-겪는-트러블슈팅">7. 자주 겪는 트러블슈팅</h2>
<h3 id="①-무한-리다이렉트-루프">① 무한 리다이렉트 루프</h3>
<p><strong>증상</strong>: <code>ERR_TOO_MANY_REDIRECTS</code>
<strong>원인</strong>: 프록시는 HTTPS로 받았는데 백엔드는 HTTP로 보여서, Spring Security가 &quot;HTTPS로 가라&quot;고 리다이렉트 → 무한 반복
<strong>해결</strong>: <code>X-Forwarded-Proto</code> 전달 + <code>server.forward-headers-strategy=NATIVE</code></p>
<h3 id="②-로그의-클라이언트-ip가-전부-172xxx">② 로그의 클라이언트 IP가 전부 172.x.x.x</h3>
<p><strong>원인</strong>: 도커 네트워크 게이트웨이 IP를 보고 있음
<strong>해결</strong>: XFF 헤더 전달 + forward-headers 설정</p>
<h3 id="③-sse--스트리밍-응답이-안-나오고-한-번에-몰아서-옴">③ SSE / 스트리밍 응답이 안 나오고 한 번에 몰아서 옴</h3>
<p><strong>원인</strong>: 프록시가 응답을 버퍼링해서 모았다가 한 번에 보냄
<strong>해결</strong>:</p>
<pre><code class="language-nginx">proxy_buffering off;</code></pre>
<p>또는 애플리케이션에서 응답 헤더 추가:</p>
<pre><code class="language-java">response.setHeader(&quot;X-Accel-Buffering&quot;, &quot;no&quot;);</code></pre>
<blockquote>
<p>Spring AI로 LLM 응답을 SSE 스트리밍할 때 로컬에서는 되는데 배포하면 한 번에 나오는 문제, 대부분 이것이다.</p>
</blockquote>
<h3 id="④-websocket이-연결-안-됨-400-bad-request">④ WebSocket이 연결 안 됨 (<code>400 Bad Request</code>)</h3>
<p><strong>원인</strong>: <code>Upgrade</code>, <code>Connection</code> 헤더 미전달
<strong>해결</strong>: <code>proxy_http_version 1.1</code> + Upgrade 헤더 설정</p>
<h3 id="⑤-504-gateway-timeout">⑤ 504 Gateway Timeout</h3>
<p><strong>원인</strong>: 프록시의 <code>proxy_read_timeout</code>(기본 60초) &lt; 애플리케이션 처리 시간
<strong>해결</strong>: 타임아웃을 프록시 &gt; 애플리케이션 순으로 여유 있게 정렬. 근본적으로는 긴 작업을 비동기로 전환</p>
<h3 id="⑥-파일-업로드-시-413-payload-too-large">⑥ 파일 업로드 시 413 Payload Too Large</h3>
<p><strong>해결</strong>: <code>client_max_body_size 50M;</code> (Nginx 기본 1MB) + Spring의 <code>spring.servlet.multipart.max-file-size</code>도 함께</p>
<h3 id="⑦-cors-에러가-계속-남">⑦ CORS 에러가 계속 남</h3>
<p><strong>주의</strong>: 리버스 프록시로 프론트/백엔드를 같은 도메인에 묶으면 CORS 자체가 사라진다. 프록시를 두고도 CORS 설정을 이중으로 하면 헤더가 중복되어 오히려 에러가 난다.</p>
<h3 id="⑧-502-bad-gateway">⑧ 502 Bad Gateway</h3>
<p><strong>원인</strong>: 백엔드가 안 떠 있거나, 프록시가 백엔드 호스트명을 못 찾음
<strong>체크</strong>: <code>docker compose ps</code>, 컨테이너 이름과 프록시 설정의 upstream 이름 일치 여부, 같은 도커 네트워크에 있는지</p>
<hr>
<h2 id="8-면접-대비-예상-질문">8. 면접 대비 예상 질문</h2>
<p><strong>Q. 포워드 프록시와 리버스 프록시의 차이는?</strong></p>
<blockquote>
<p>프록시가 누구를 대리하느냐가 다릅니다. 포워드 프록시는 클라이언트를 대리해서 클라이언트가 명시적으로 설정하고, 서버는 실제 클라이언트를 알 수 없습니다. 리버스 프록시는 서버를 대리해서 서비스 운영자가 배치하고, 클라이언트는 뒤에 어떤 서버가 있는지 알 수 없습니다.</p>
</blockquote>
<p><strong>Q. 리버스 프록시를 왜 두나요?</strong></p>
<blockquote>
<p>TLS 종료로 인증서 관리를 일원화하고, 애플리케이션 포트를 외부에 노출하지 않아 공격 표면을 줄입니다. 또 경로 기반 라우팅과 로드밸런싱으로 무중단 배포와 수평 확장이 가능해집니다.</p>
</blockquote>
<p><strong>Q. 리버스 프록시를 두면 애플리케이션에서 뭐가 달라지나요?</strong></p>
<blockquote>
<p>원 요청 정보를 잃습니다. <code>getRemoteAddr()</code>가 프록시 IP를 반환하고, HTTPS로 들어와도 스킴이 http로 보입니다. <code>X-Forwarded-*</code> 헤더를 프록시가 붙이고 애플리케이션이 이를 신뢰하도록 설정해야 합니다. Spring Boot에서는 <code>server.forward-headers-strategy</code>로 처리합니다.</p>
</blockquote>
<p><strong>Q. X-Forwarded-For를 그냥 믿어도 되나요?</strong></p>
<blockquote>
<p>안 됩니다. 클라이언트가 위조할 수 있습니다. 최외곽 프록시가 값을 덮어쓰고, 신뢰 가능한 내부 프록시 IP 대역만 화이트리스트로 두고 그 뒤 값만 사용해야 합니다.</p>
</blockquote>
<p><strong>Q. 로드밸런서와 리버스 프록시의 차이는?</strong></p>
<blockquote>
<p>리버스 프록시가 상위 개념입니다. 로드밸런싱은 리버스 프록시의 기능 중 하나이고, L4 로드밸런서는 HTTP를 해석하지 않으므로 리버스 프록시라 부르기 어렵습니다.</p>
</blockquote>
<p><strong>Q. API Gateway는요?</strong></p>
<blockquote>
<p>리버스 프록시 기능에 인증/인가, 레이트 리밋, 요청·응답 변환, API 버저닝 같은 애플리케이션 관심사를 더한 것입니다. MSA에서 각 서비스가 공통 관심사를 중복 구현하지 않도록 앞단으로 뽑아냅니다.</p>
</blockquote>
<hr>
<h2 id="9-직접-해보기-체크리스트">9. 직접 해보기 체크리스트</h2>
<ul>
<li><input disabled="" type="checkbox"> <code>mitmproxy</code> 설치해서 포워드 프록시로 로컬 앱 트래픽 들여다보기</li>
<li><input disabled="" type="checkbox"> Docker Compose로 <code>Nginx + Spring Boot 2대</code> 띄우고 로드밸런싱 확인 (응답에 인스턴스 ID 찍기)</li>
<li><input disabled="" type="checkbox"> <code>proxy_set_header</code> 4줄을 지웠다 넣었다 하며 <code>request.getRemoteAddr()</code> 변화 관찰</li>
<li><input disabled="" type="checkbox"> Caddy로 자동 HTTPS 붙이고 <code>curl -v</code> 로 인증서 확인</li>
<li><input disabled="" type="checkbox"> <code>proxy_buffering on/off</code> 바꿔가며 SSE 응답 흐름 비교</li>
<li><input disabled="" type="checkbox"> 백엔드 컨테이너를 하나 죽여보고 헬스체크·페일오버 동작 확인</li>
<li><input disabled="" type="checkbox"> <code>expose</code> vs <code>ports</code> 차이를 <code>nmap</code>이나 외부에서 curl로 검증</li>
</ul>
<hr>
<h2 id="10-한-장-요약">10. 한 장 요약</h2>
<pre><code>                    ┌──────────────────────────────┐
                    │  프록시 = 대리인             │
                    └──────────────────────────────┘
                                 │
              ┌──────────────────┴──────────────────┐
              ▼                                      ▼
      FORWARD PROXY                          REVERSE PROXY
      클라이언트의 대리인                     서버의 대리인
              │                                      │
   클라이언트가 설정함                    운영자가 배치함
   서버는 클라이언트를 모름                클라이언트는 서버를 모름
              │                                      │
   용도:                                  용도:
   · 사내 접근 제어                        · TLS 종료
   · 캐싱, 대역폭 절약                     · 로드밸런싱
   · 익명성, IP 우회                       · 경로 라우팅
   · 트래픽 감사                           · 정적 파일 서빙
                                          · 보안(WAF, 레이트리밋)
   Squid, mitmproxy                        Nginx, Caddy, ALB</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Hearth 셋업 로그]]></title>
            <link>https://velog.io/@gitit-sehyeon/Hearth-%EC%85%8B%EC%97%85-%EB%A1%9C%EA%B7%B8</link>
            <guid>https://velog.io/@gitit-sehyeon/Hearth-%EC%85%8B%EC%97%85-%EB%A1%9C%EA%B7%B8</guid>
            <pubDate>Wed, 22 Jul 2026 09:05:33 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/4a1b7fe4-9a8a-4aee-afa0-46452d29b1df/image.png" alt=""></p>
<h2 id="1-hearth가-뭔지">1. Hearth가 뭔지</h2>
<p>로컬 개발 환경을 위한 <strong>서비스 컨트롤 플레인</strong>. 여러 개의 로컬 프로젝트(각자 다른 포트에서 실행됨)를 <code>http://localhost</code> 하나의 진입점 아래 경로 기반으로 묶어주는 역할.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>백엔드</td>
<td>FastAPI + uvicorn, <code>127.0.0.1:8080</code></td>
</tr>
<tr>
<td>├ UI</td>
<td><code>/</code></td>
</tr>
<tr>
<td>├ REST API</td>
<td><code>/api</code></td>
</tr>
<tr>
<td>└ MCP 엔드포인트</td>
<td><code>/mcp</code></td>
</tr>
<tr>
<td>서비스 레지스트리</td>
<td>SQLite (<code>routes</code> 테이블)</td>
</tr>
<tr>
<td>라우팅</td>
<td>nginx (<code>runtime/nginx/conf.d/routes.conf</code>) — 등록 시 자동 생성 + <code>nginx -t</code> 검증 후 reload</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>중요</strong>: hearth는 앱 프로세스를 직접 실행(spawn)하지 않는다. <code>frontend_port</code> / <code>backend_port</code>로 서비스를 등록하면 그 포트에 nginx location만 연결해줄 뿐, <code>npm run dev</code> / <code>uvicorn</code> 같은 실제 프로세스는 사용자가 직접 띄워야 함.</p>
</blockquote>
<ul>
<li>launchd(<code>launchd/com.hearth.plist</code>)로 hearth 백엔드 자체를 로그인 시 상시 기동 가능 (<code>KeepAlive=true</code>, 크래시 시 자동 재시작).</li>
<li><strong>선택적 확장</strong>: colima(<code>--kubernetes</code>)로 k3s 클러스터를 띄우고 공유 PostgreSQL(pgvector) + Gateway API(NGINX Gateway Fabric)를 배포하면, 앱을 로컬 프로세스가 아니라 k8s Deployment로 올리는 경로도 지원.</li>
</ul>
<hr>
<h2 id="2-오늘-터미널에서-친-명령-시간순">2. 오늘 터미널에서 친 명령 (시간순)</h2>
<h3 id="2-1-기본-셋업--도구-설치--sudoers--백엔드-기동">2-1. 기본 셋업 — 도구 설치 + sudoers + 백엔드 기동</h3>
<pre><code class="language-bash">brew install nginx node python@3.14
./scripts/setup-sudoers.sh
./scripts/start.sh</code></pre>
<p><code>start.sh</code>가 venv 자동 생성 + 프론트 빌드(<code>frontend/package-lock.json</code> 변경 원인) + uvicorn <code>:8080</code> 기동까지 처리.</p>
<h3 id="2-2-nginx-install--start">2-2. nginx install / start</h3>
<pre><code class="language-bash">curl -X POST localhost:8080/api/nginx/install
curl -X POST localhost:8080/api/nginx/start</code></pre>
<h3 id="2-3-launchd-상시-기동-등록">2-3. launchd 상시 기동 등록</h3>
<pre><code class="language-bash">cp launchd/com.hearth.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.hearth.plist</code></pre>
<p>등록 전 plist 파일에 오타 발견 → 세션 중 같이 수정:</p>
<ul>
<li><code>StandardOutPath</code> / <code>StandardErrorPath</code>에 <code>&gt;</code>, <code>&lt;</code> 문자가 잘못 섞여 있었음</li>
<li>경로 대소문자(<code>Projects</code> → <code>projects</code>)가 실제 리포 경로와 불일치</li>
<li>수정 후 <code>plutil -lint launchd/com.hearth.plist</code> → OK 확인</li>
</ul>
<pre><code class="language-bash">launchctl list | grep hearth
tail -f runtime/backend.out.log</code></pre>
<p><strong>결과</strong>: <code>com.hearth</code>의 PID가 <code>-</code>, 종료코드 3, 로그에 <code>[start] uvicorn ...</code> 배너가 반복 → <strong>크래시 루프</strong>.</p>
<h3 id="2-4-크래시-루프-디버깅">2-4. 크래시 루프 디버깅</h3>
<pre><code class="language-bash">tail -n 60 runtime/backend.err.log
# → ERROR: [Errno 48] address already in use (127.0.0.1:8080)

lsof -nP -iTCP:8080 -sTCP:LISTEN
# → PID 52480 (launchd 등록 전에 수동으로 띄워둔 ./scripts/start.sh 프로세스)

ps -p 52480 -o pid,ppid,lstart,command
kill 52480

launchctl list com.hearth
curl -s -o /dev/null -w &#39;%{http_code}\n&#39; http://localhost:8080
# → 200, PID가 안정적인 새 프로세스로 정착 (예: 70049)</code></pre>
<p><strong>원인</strong>: 수동 기동 프로세스가 8080을 물고 있어서 launchd가 올린 인스턴스가 bind 실패 → KeepAlive가 재시작 → 무한 반복.</p>
<h3 id="2-5-colima-기동">2-5. colima 기동</h3>
<pre><code class="language-bash">brew install colima docker kubectl helm
colima status 2&gt;/dev/null || echo &quot;미기동&quot;
colima start --kubernetes --cpu 6 --memory 12 --disk 100
kubectl config use-context colima
kubectl get nodes</code></pre>
<h3 id="2-6-colima-상태-검증">2-6. colima 상태 검증</h3>
<pre><code class="language-bash">colima status
colima list
kubectl config current-context
kubectl get nodes -o wide
kubectl get pods -A</code></pre>
<p><strong>결과</strong>: 노드 1대 Ready (v1.35.0+k3s1), <code>kube-system</code>의 coredns / local-path-provisioner / metrics-server 3개 파드 Running. postgres·Gateway는 아직 미배포(bare cluster).</p>
<hr>
<h2 id="3-앞으로-쳐야-할-명령--k8sreadmemd-재현-절">3. 앞으로 쳐야 할 명령 — <code>k8s/README.md</code> &quot;재현&quot; 절</h2>
<p><code>provision_db</code>, <code>k8s_httproute_manifest</code> 적용(apply)이 작동하려면 아래를 순서대로 실행.</p>
<h3 id="1-공유-postgresql-이미-있으면-생략">1) 공유 PostgreSQL (이미 있으면 생략)</h3>
<pre><code class="language-bash">kubectl create namespace data
PW=$(openssl rand -hex 24)
kubectl create secret generic postgres-superuser -n data \
  --from-literal=POSTGRES_USER=postgres --from-literal=POSTGRES_PASSWORD=&quot;$PW&quot;
kubectl apply -f k8s/postgres/postgres.yaml</code></pre>
<h3 id="2-gateway-api--nginx-gateway-fabric">2) Gateway API + NGINX Gateway Fabric</h3>
<pre><code class="language-bash">kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml

helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric --version 2.6.3 \
  -n nginx-gateway --create-namespace --wait

kubectl patch nginxproxy ngf-proxy-config -n nginx-gateway --type merge \
  -p &#39;{&quot;spec&quot;:{&quot;kubernetes&quot;:{&quot;service&quot;:{&quot;type&quot;:&quot;NodePort&quot;}}}}&#39;

kubectl apply -f k8s/gateway/gateway.yaml
kubectl apply -f k8s/gateway/httproutes.yaml</code></pre>
<h3 id="3-호스트-nginx-route를-gateway-nodeport로-연결">3) 호스트 nginx route를 Gateway NodePort로 연결</h3>
<p>hearth의 <code>update_route</code> 또는 <code>/api/routes</code> 사용. NodePort 확인:</p>
<pre><code class="language-bash">kubectl get svc -n default -l gateway.networking.k8s.io/gateway-name=hearth-gw \
  -o jsonpath=&#39;{.items[0].spec.ports[0].nodePort}&#39;</code></pre>
<h3 id="완료-후-확인">완료 후 확인</h3>
<pre><code class="language-bash">curl -s localhost:8080/api/k8s/status   # GatewayClass/Gateway/postgres/워크로드 상태</code></pre>
<h3 id="실제-서비스를-k8s에-올릴-때-예시">실제 서비스를 k8s에 올릴 때 (예시)</h3>
<pre><code class="language-bash"># 이미지 빌드 (레지스트리/push 불필요, imagePullPolicy: Never로 로컬 태그 그대로 사용)
docker build -t myapp:local .

# Deployment/Service 매니페스트 작성 후 적용 (직접 작성하거나 k8s_apply 사용)
kubectl apply -f myapp-deployment.yaml

# HTTPRoute 생성 — MCP/REST의 k8s_httproute_manifest(...) 호출 후
kubectl apply -f &lt;생성된 httproute.yaml&gt;</code></pre>
<p>전용 DB가 필요하면 MCP/REST의 <code>provision_db(svc)</code> 호출.</p>
<hr>
<h2 id="4-상태-확인용-커맨드-모음">4. 상태 확인용 커맨드 모음</h2>
<pre><code class="language-bash">launchctl list | grep hearth              # hearth 백엔드 launchd 상태
tail -f runtime/backend.out.log           # 백엔드 stdout
tail -f runtime/backend.err.log           # 백엔드 stderr
colima status                             # colima/k3s 상태
kubectl get nodes                         # 클러스터 노드
kubectl get pods -A                       # 전체 파드
lsof -nP -iTCP:8080 -sTCP:LISTEN          # 8080 포트 점유 프로세스 확인</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[04. NHN Container Service (NCS)]]></title>
            <link>https://velog.io/@gitit-sehyeon/04.-NHN-Container-Service-NCS</link>
            <guid>https://velog.io/@gitit-sehyeon/04.-NHN-Container-Service-NCS</guid>
            <pubDate>Wed, 22 Jul 2026 04:49:33 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/5869ac0e-3675-4af7-bc3c-bf542eeeb3eb/image.png" alt=""></p>
<h2 id="0-한-문장-정의">0. 한 문장 정의</h2>
<blockquote>
<p><strong>클러스터를 만들거나 관리하지 않고, 컨테이너 사양만 정의하면 바로 실행해주는 서버리스 컨테이너 서비스.</strong></p>
</blockquote>
<p>AWS로 치면 <strong>ECS Fargate</strong>에 가깝다. 노드(서버)라는 개념이 사용자에게 노출되지 않는다.</p>
<hr>
<h2 id="1-nks와의-근본적-차이">1. NKS와의 근본적 차이</h2>
<table>
<thead>
<tr>
<th></th>
<th>NKS</th>
<th>NCS</th>
</tr>
</thead>
<tbody><tr>
<td>추상화 단위</td>
<td>클러스터 → 노드 → 파드</td>
<td>템플릿 → 워크로드 → 작업</td>
</tr>
<tr>
<td>노드 관리</td>
<td>사용자가 사양·개수 결정</td>
<td><strong>없음</strong> (사용자가 신경 안 씀)</td>
</tr>
<tr>
<td>학습 곡선</td>
<td>높음 (K8s 전체)</td>
<td>낮음 (콘솔 폼 작성 수준)</td>
</tr>
<tr>
<td>유연성</td>
<td>매우 높음 (생태계 전체 활용)</td>
<td>제한적</td>
</tr>
<tr>
<td>kubectl / Helm / ArgoCD</td>
<td>o</td>
<td>x</td>
</tr>
<tr>
<td>적합</td>
<td>MSA, 복잡한 배포 전략</td>
<td>단순 API, 배치, 짧은 GPU 작업</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>판단 기준</strong>: &quot;이 워크로드에 쿠버네티스의 복잡성을 지불할 가치가 있나?&quot;
API 서버 1~2개면 NCS가 훨씬 빠르다. 서비스가 10개 넘어가면 NKS.</p>
</blockquote>
<hr>
<h2 id="2-핵심-개념-3층-구조">2. 핵심 개념 3층 구조</h2>
<pre><code>템플릿 (Template)          &quot;어떤 컨테이너를 어떻게 만들지&quot; — 설계도
     │ 참조(Reference)
     ▼
워크로드 (Workload)        &quot;그 설계도로 몇 개를 어떻게 띄울지&quot; — 실행 명세
     │ 생성(Create)
     ▼
작업 (Task) × N            실제로 돌아가는 컨테이너 묶음 — 실체</code></pre><h3 id="2-1-템플릿">2-1. 템플릿</h3>
<p>컨테이너의 <strong>사양을 정의</strong>한다. 여기에 들어가는 것:</p>
<p><strong>기본 정보</strong></p>
<ul>
<li>이름, 설명</li>
<li>연결할 VPC / 서브넷</li>
</ul>
<p><strong>컨테이너 사양 (1개 이상)</strong></p>
<ul>
<li>컨테이너 이미지 URL (보통 NCR 주소)</li>
<li>CPU / Memory / GPU 사용 여부</li>
<li>컨테이너 포트</li>
<li>연결할 NAS 스토리지 여부</li>
</ul>
<blockquote>
<p><strong>템플릿을 만들어도 컨테이너는 생기지 않는다.</strong>
템플릿은 워크로드를 만들기 위한 <strong>틀</strong>일 뿐이다. 워크로드를 생성해야 컨테이너가 뜬다.
템플릿을 유지해두고 필요할 때만 워크로드를 생성해 컨테이너를 굴리는 사용법도 가능하다.
(→ 배치 작업, 이벤트성 워크로드에 유용)</p>
</blockquote>
<h3 id="2-2-워크로드">2-2. 워크로드</h3>
<p>템플릿을 <strong>참조</strong>해서 실행 방식을 정의한다.</p>
<ul>
<li>참조할 템플릿</li>
<li><strong>워크로드 실행 수</strong> (= 몇 개의 작업을 띄울지)</li>
<li>로드 밸런서 사용 여부</li>
<li>플로팅 IP 사용 여부</li>
</ul>
<h3 id="2-3-작업-task">2-3. 작업 (Task)</h3>
<p>워크로드를 생성하면 템플릿에 정의된 컨테이너들이 <strong>작업 하위에</strong> 만들어진다.</p>
<ul>
<li>작업 하나에는 <strong>VPC 서브넷에서 할당받은 IP</strong>가 부여된다</li>
<li>VPC 내부에서는 <code>작업IP:컨테이너포트</code>로 직접 접근 가능</li>
<li>워크로드 실행 수만큼 작업이 복제된다</li>
</ul>
<blockquote>
<p><strong>쿠버네티스 대응</strong>: 작업(Task) ≈ <strong>Pod</strong>.
템플릿의 컨테이너 여러 개가 하나의 작업 안에 함께 뜨는 것도 Pod와 같은 구조다.</p>
</blockquote>
<hr>
<h2 id="3-첨부-다이어그램-해석">3. 첨부 다이어그램 해석</h2>
<p><img src="https://velog.velcdn.com/images/gitit-sehyeon/post/5e80f26c-ecc8-4c30-9950-b2c9fb7431d1/image.png" alt=""></p>
<h3 id="step-1--템플릿-정의">Step 1 — 템플릿 정의</h3>
<pre><code>Template
├─ Basic Info:  Name, Description
├─ VPC to connect: Subnet
├─ Container 1: 이미지 URL, CPU/Mem, GPU 없음, 포트 80,  스토리지 없음
└─ Container 2: 이미지 URL, CPU/Mem, GPU 없음, 포트 443, 스토리지 Online NAS</code></pre><p>컨테이너 사양 <strong>2개</strong>를 한 템플릿에 정의했다.</p>
<h3 id="step-2--워크로드-정의-템플릿-참조">Step 2 — 워크로드 정의 (템플릿 참조)</h3>
<pre><code>Workload
├─ Basic Info: Name, Description
├─ Number of Running Workloads: 2   ← 작업 2개
├─ Load Balancer: Use
└─ Floating IP: Use</code></pre><h3 id="step-3--실제-구성-결과">Step 3 — 실제 구성 결과</h3>
<pre><code>                 ┌──────────── Load Balancer ────────────┐
   Floating IP ──┤  포트 80  → 각 작업의 Container 1로 분배 │
   + 도메인      │  포트 443 → 각 작업의 Container 2로 분배 │
                 └───────┬───────────────────┬────────────┘
                         │                   │
                ┌────────▼────────┐ ┌────────▼────────┐
                │     Task 1      │ │     Task 2      │
                │ ┌────┐ ┌──────┐ │ │ ┌────┐ ┌──────┐ │
                │ │C1  │ │ C2   │ │ │ │C1  │ │ C2   │ │
                │ │:80 │ │:443  │ │ │ │:80 │ │:443  │ │
                │ └────┘ └──┬───┘ │ │ └────┘ └──┬───┘ │
                │    IP     │     │ │    IP     │     │
                └───────────┼─────┘ └───────────┼─────┘
                            │                   │
                            └────► Online NAS ◄─┘
                                   (Volume 공유, 같은 Mount Point)
                                        IP
        ──────────────────── VPC Subnet ────────────────────</code></pre><p><strong>읽어낼 포인트</strong></p>
<ol>
<li><strong>템플릿 1개 × 실행 수 2 = 작업 2개</strong>, 각 작업에 컨테이너 2개씩 → 총 컨테이너 4개</li>
<li><strong>LB가 포트별로 다른 컨테이너에 분배</strong>한다<ul>
<li>80 → 두 작업의 Container 1</li>
<li>443 → 두 작업의 Container 2</li>
</ul>
</li>
<li><strong>Online NAS 볼륨은 여러 작업이 공유</strong>한다 (Container 2가 모두 같은 마운트 포인트 사용)
→ K8s의 <code>ReadWriteMany</code> 개념. 블록 스토리지로는 안 되는 구조</li>
<li><strong>모든 구성 요소가 같은 VPC 서브넷 안</strong>에 있고 각각 IP를 가진다</li>
<li><strong>플로팅 IP를 켜면 LB에 FIP와 도메인이 붙어</strong> 외부 접근이 가능해진다</li>
</ol>
<blockquote>
<p><strong>지정한 VPC 서브넷에 인터넷 게이트웨이가 연결되어 있지 않으면 플로팅 IP와 도메인을 사용할 수 없다.</strong>
외부 노출이 목적인데 접속이 안 되면 이걸 제일 먼저 확인.</p>
</blockquote>
<hr>
<h2 id="4-쿠버네티스-개념-대응표">4. 쿠버네티스 개념 대응표</h2>
<table>
<thead>
<tr>
<th>NCS</th>
<th>Kubernetes</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>템플릿</td>
<td>Pod Template (Deployment의 <code>spec.template</code>)</td>
<td>컨테이너 사양 정의</td>
</tr>
<tr>
<td>워크로드</td>
<td>Deployment / ReplicaSet</td>
<td>몇 개를 어떻게 실행할지</td>
</tr>
<tr>
<td>워크로드 실행 수</td>
<td><code>replicas</code></td>
<td>복제 개수</td>
</tr>
<tr>
<td>작업 (Task)</td>
<td><strong>Pod</strong></td>
<td>실행 단위, 자체 IP 보유</td>
</tr>
<tr>
<td>컨테이너</td>
<td>Container</td>
<td>동일</td>
</tr>
<tr>
<td>로드 밸런서</td>
<td><code>Service (type: LoadBalancer)</code></td>
<td>트래픽 분배</td>
</tr>
<tr>
<td>플로팅 IP + 도메인</td>
<td><code>EXTERNAL-IP</code> + DNS</td>
<td>외부 노출</td>
</tr>
<tr>
<td>Online NAS 마운트</td>
<td>PVC (<code>ReadWriteMany</code>)</td>
<td>공유 영속 볼륨</td>
</tr>
<tr>
<td>VPC 서브넷</td>
<td>클러스터 네트워크</td>
<td>네트워크 격리</td>
</tr>
</tbody></table>
<p><strong>이 표가 학습에 유용한 이유</strong>: NCS를 먼저 만져보면 K8s의 추상 개념이 훨씬 잘 이해된다.
반대로 K8s를 알면 NCS 콘솔은 30분이면 익힌다.</p>
<hr>
<h2 id="5-ncs를-선택하면-좋은-경우--아쉬운-경우">5. NCS를 선택하면 좋은 경우 / 아쉬운 경우</h2>
<h3 id="잘-맞는-경우">잘 맞는 경우</h3>
<ul>
<li>단일 API 서버, 간단한 웹 앱</li>
<li>주기적 배치 작업 (템플릿만 유지하다 필요할 때 워크로드 생성)</li>
<li>짧게 쓰는 GPU 워크로드 (템플릿에서 GPU 사용 설정 가능)</li>
<li>인프라 담당자가 없는 소규모 팀</li>
<li>PoC / 데모 / 해커톤 — 빠르게 띄우는 게 최우선</li>
</ul>
<h3 id="아쉬운-경우">아쉬운 경우</h3>
<ul>
<li>서비스가 많고 서로 호출하는 MSA (서비스 디스커버리를 직접 처리해야 함)</li>
<li>Canary / Blue-Green 등 정교한 배포 전략</li>
<li>Helm, ArgoCD, Istio 등 K8s 생태계 도구 활용</li>
<li>커스텀 오퍼레이터, CRD가 필요한 경우</li>
<li>세밀한 스케줄링(affinity, taint 등) 제어</li>
</ul>
<hr>
<h2 id="6-실습-시나리오-제안">6. 실습 시나리오 제안</h2>
<p><strong>목표: 같은 Spring Boot 앱을 NCS와 NKS 양쪽에 배포해 차이를 체감하기</strong></p>
<ol>
<li>NCR에 <code>my-api:1.0.0</code> 이미지 push (03 문서 참고)</li>
<li><strong>NCS</strong><ul>
<li>템플릿 생성: 이미지 URL = NCR 주소, CPU 1 / Mem 2GB, 포트 8080</li>
<li>워크로드 생성: 실행 수 2, LB 사용, 플로팅 IP 사용</li>
<li>발급된 도메인으로 <code>/actuator/health</code> 호출 확인</li>
<li>워크로드 실행 수를 4로 변경 → 작업 개수 변화 관찰</li>
</ul>
</li>
<li><strong>NKS</strong><ul>
<li>동일 이미지로 Deployment(replicas: 2) + Service(LoadBalancer) 배포</li>
<li><code>kubectl scale deployment my-api --replicas=4</code></li>
<li>이미지 태그를 1.0.1로 올려 롤링 업데이트 → <code>kubectl rollout undo</code>로 롤백</li>
</ul>
</li>
<li><strong>비교 정리</strong><ul>
<li>첫 배포까지 걸린 시간</li>
<li>스케일 조정 방법의 차이</li>
<li>롤백이 가능한가 / 얼마나 쉬운가</li>
<li>로그·메트릭 확인 방법</li>
</ul>
</li>
</ol>
<p>이 비교를 직접 해보고 정리해두면, 나중에 &quot;왜 K8s를 썼는가&quot;를 설명할 근거가 생긴다.</p>
<hr>
<h2 id="7-체크리스트">7. 체크리스트</h2>
<ul>
<li><input disabled="" type="checkbox"> 템플릿과 워크로드의 차이를 설명할 수 있다</li>
<li><input disabled="" type="checkbox"> 템플릿만 만들면 컨테이너가 안 뜬다는 걸 안다</li>
<li><input disabled="" type="checkbox"> 작업(Task)이 K8s의 Pod에 대응된다는 걸 이해했다</li>
<li><input disabled="" type="checkbox"> LB가 포트별로 다른 컨테이너에 분배되는 구조를 그림으로 그릴 수 있다</li>
<li><input disabled="" type="checkbox"> 플로팅 IP가 안 될 때 인터넷 게이트웨이를 먼저 확인해야 함을 안다</li>
<li><input disabled="" type="checkbox"> NCS와 NKS 중 어떤 상황에 무엇을 쓸지 판단 기준이 있다</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>