<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Braincrew Tech - Product</title>
        <link>https://tech.brain-crew.com/product</link>
        <description>프로덕트 회고, 의사결정, 케이스 스터디</description>
        <lastBuildDate>Fri, 11 Sep 2026 15:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ko</language>
        <item>
            <title><![CDATA[늦게 돌아온 추출 워커의 완료 처리와 소유권 펜싱]]></title>
            <link>https://tech.brain-crew.com/product/extraction-worker-lease-fencing</link>
            <guid>https://tech.brain-crew.com/product/extraction-worker-lease-fencing</guid>
            <pubDate>Fri, 11 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[문서 추출 작업의 임대가 만료돼도 이전 워커는 계속 실행될 수 있다. Deep Connector의 작업 점유와 완료 조건을 읽고, 소유권이 바뀐 뒤 도착한 결과를 거절하는 규칙을 설명용 모델로 확인했다.]]></description>
            <content:encoded><![CDATA[<p>문서 추출 워커가 응답하지 않으면 다른 워커가 작업을 이어받을 수 있어야 한다. 그런데 응답이 늦다는 이유만으로 이전 워커가 종료됐다고 볼 수는 없다. 새 워커가 결과를 저장한 뒤 이전 워커도 완료를 보고할 수 있다.</p>
<p>Deep Connector의 추출 코드를 검토하면서 작업을 가져오는 조건과 결과를 확정하는 조건을 나눠 읽었다. 작업을 처음 점유할 때의 행 잠금만으로는 나중에 돌아온 워커의 쓰기까지 막을 수 없기 때문이다. 결과를 확정할 때도 DB에 기록된 현재 소유자를 확인해야 한다.</p>
<p>아래 순서는 지연 상황을 설명하려고 만든 가정이며 실제 장애 회고가 아니다. 예제는 소유권 조건을 축약한 메모리 모델로, 실제 제품 코드나 다중 인스턴스 DB 실험을 실행한 결과와 구분한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="임대-만료와-워커-종료의-차이">임대 만료와 워커 종료의 차이<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%EC%9E%84%EB%8C%80-%EB%A7%8C%EB%A3%8C%EC%99%80-%EC%9B%8C%EC%BB%A4-%EC%A2%85%EB%A3%8C%EC%9D%98-%EC%B0%A8%EC%9D%B4" class="hash-link" aria-label="임대 만료와 워커 종료의 차이에 대한 직접 링크" title="임대 만료와 워커 종료의 차이에 대한 직접 링크" translate="no">​</a></h2>
<p>워커 A가 문서 하나를 점유하고 추출을 시작했다고 하자. A가 임대(lease)를 갱신하지 못해 만료 시각을 넘기면 워커 B가 같은 작업을 다시 점유할 수 있다. 그동안 A의 추출 요청은 아직 실행 중일 수 있다.</p>
<p>이때 lease는 다른 워커가 작업을 인계받을 조건이지, A의 프로세스를 종료하는 명령이 아니다. B가 결과를 확정한 뒤 A가 돌아오더라도 A의 결과를 현재 결과로 받아들이지 않아야 한다. 여기서 소유권 펜싱은 현재 소유자가 아닌 워커의 DB 상태 변경을 거절하는 규칙을 뜻한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="작업-점유와-결과-확정의-분리">작업 점유와 결과 확정의 분리<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%EC%9E%91%EC%97%85-%EC%A0%90%EC%9C%A0%EC%99%80-%EA%B2%B0%EA%B3%BC-%ED%99%95%EC%A0%95%EC%9D%98-%EB%B6%84%EB%A6%AC" class="hash-link" aria-label="작업 점유와 결과 확정의 분리에 대한 직접 링크" title="작업 점유와 결과 확정의 분리에 대한 직접 링크" translate="no">​</a></h2>
<p>점유 함수인 <code>claimOne()</code>은 트랜잭션 안에서 후보 행에 <code>pessimistic_write</code> 잠금을 요청하고 <code>skip_locked</code>를 지정한다. 후보는 새로 발견한 문서, 재시도 시각이 된 문서, 추출 중이지만 lease가 만료된 문서다. PostgreSQL의 <code>SKIP LOCKED</code>는 즉시 잠글 수 없는 행을 건너뛰므로 여러 소비자가 큐 형태의 테이블에서 작업을 가져올 때 쓸 수 있다.</p>
<p>행을 선택한 뒤에는 조건부 UPDATE로 점유 가능 상태를 다시 확인한다. 정확히 한 행을 변경했을 때 새 소유자 토큰과 만료 시각을 기록하고 시도 횟수를 늘린 것으로 인정한다. 이 트랜잭션은 문서 다운로드나 추출이 끝날 때까지 유지되지 않는다.</p>
<p>완료 함수 <code>complete()</code>는 문서 ID만으로 갱신하지 않는다. 문서 ID, <code>extraction_owner_token</code>, 현재 상태 <code>extracting</code>이 모두 일치해야 결과 경로와 체크섬을 기록하고 상태를 <code>indexed</code>로 바꾼다. 갱신 함수 <code>renew()</code>와 실패 함수 <code>fail()</code>에도 같은 소유권 조건이 있다.</p>
<p>검토한 구현에서 소유자 토큰은 기본적으로 UUID이며, 크기를 비교하는 단조 증가 번호가 아니다. 저장된 토큰과 요청 토큰의 일치 여부로 소유자를 구분한다. 따라서 모든 외부 저장소가 이 토큰으로 오래된 쓰기를 거절한다고 해석해서는 안 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="만료-시각만으로-거절하지-않는-완료-조건">만료 시각만으로 거절하지 않는 완료 조건<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%EB%A7%8C%EB%A3%8C-%EC%8B%9C%EA%B0%81%EB%A7%8C%EC%9C%BC%EB%A1%9C-%EA%B1%B0%EC%A0%88%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%99%84%EB%A3%8C-%EC%A1%B0%EA%B1%B4" class="hash-link" aria-label="만료 시각만으로 거절하지 않는 완료 조건에 대한 직접 링크" title="만료 시각만으로 거절하지 않는 완료 조건에 대한 직접 링크" translate="no">​</a></h2>
<p><code>complete()</code>와 <code>renew()</code>의 조건에는 lease 만료 시각 비교가 없다. 만료됐더라도 아직 다른 워커가 재점유하지 않았고 상태와 토큰이 그대로라면 기존 워커의 완료나 갱신이 허용될 수 있다. 반대로 B가 재점유해 토큰이 바뀌었다면 A는 예전 토큰으로 완료할 수 없다.</p>
<p>이 차이를 생략하면 “만료된 워커의 모든 요청은 거절된다”라는 잘못된 설명이 된다. 이 구현에서 만료 시각은 재점유 후보를 정하고, 결과 갱신 시점의 토큰과 상태는 완료 권한을 정한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="순서를-고정한-설명용-모델">순서를 고정한 설명용 모델<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%EC%88%9C%EC%84%9C%EB%A5%BC-%EA%B3%A0%EC%A0%95%ED%95%9C-%EC%84%A4%EB%AA%85%EC%9A%A9-%EB%AA%A8%EB%8D%B8" class="hash-link" aria-label="순서를 고정한 설명용 모델에 대한 직접 링크" title="순서를 고정한 설명용 모델에 대한 직접 링크" translate="no">​</a></h2>
<p>다음 Python 코드는 DB 한 행을 사전으로 표현한다. 최초 점유와 만료 후 재점유, 완료 시 토큰과 상태 비교만 남겼으며 재시도, 트랜잭션, 타이머, 파일 쓰기는 구현하지 않았다. 함수 호출을 순서대로 실행하므로 실제 동시성이나 DB 잠금 동작을 검증하는 코드가 아니다.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lease_model.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">new_row</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"status"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"discovered"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"owner"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"expires"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"result"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">claim</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> owner</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> now</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> lease</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">10</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    eligible </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"status"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"discovered"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"status"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"extracting"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"expires"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;=</span><span class="token plain"> now</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> eligible</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">False</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    row</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">update</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">status</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"extracting"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> owner</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">owner</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> expires</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">now </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> lease</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">complete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> owner</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"status"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"extracting"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"owner"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> owner</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">False</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    row</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">update</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">status</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"indexed"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> owner</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> expires</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">result</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">row </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> new_row</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> claim</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> now</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> claim</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> now</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">9</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> claim</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> now</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">10</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">stale_during_b </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> complete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A-result"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> stale_during_b</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"owner"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"result"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">is</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">winner </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> complete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B-result"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">late_after_b </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> complete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A-result"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> winner </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> late_after_b</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"result"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"B-result"</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"A completion while B owns:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> stale_during_b</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"B completion:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> winner</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"A completion after B finishes:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> late_after_b</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"Stored result:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"result"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">row </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> new_row</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> claim</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> now</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">now </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">11</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> now </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"expires"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">expired_without_reclaim </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> complete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"A-result"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> expired_without_reclaim</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"Expired A without reclaim:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> expired_without_reclaim</span><span class="token punctuation" style="color:#393A34">)</span><br></span></code></pre></div></div>
<p>코드를 <code>lease_model.py</code>로 저장하고 <code>python3 lease_model.py</code>로 실행하면 된다. Python 3.9.6에서 위 코드 전체를 실행해 모든 assertion이 통과했으며 표준 출력은 다음과 같았다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">A completion while B owns: False</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">B completion: True</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">A completion after B finishes: False</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Stored result: B-result</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Expired A without reclaim: True</span><br></span></code></pre></div></div>
<p>B가 추출 중일 때 A를 거절한 결과는 토큰 비교가 필요하다는 점을 보여준다. B가 완료한 뒤에는 토큰뿐 아니라 상태도 달라져 A의 완료 조건이 성립하지 않는다. 마지막 줄은 만료만으로 완료를 거절하지 않는 구현상의 차이를 확인한 결과다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="타이머로-취소되지-않는-외부-작업">타이머로 취소되지 않는 외부 작업<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%ED%83%80%EC%9D%B4%EB%A8%B8%EB%A1%9C-%EC%B7%A8%EC%86%8C%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%99%B8%EB%B6%80-%EC%9E%91%EC%97%85" class="hash-link" aria-label="타이머로 취소되지 않는 외부 작업에 대한 직접 링크" title="타이머로 취소되지 않는 외부 작업에 대한 직접 링크" translate="no">​</a></h2>
<p>실제 워커는 다운로드, 추출, 미러 파일 쓰기를 각각 <code>withLease()</code>로 감싼다. 각 단계 시작 전에 한 번 갱신하고 이후에는 <code>Math.max(100, Math.floor(leaseMs / 3))</code> 밀리초를 지연값으로 타이머를 예약한다. 이 값은 정확한 실행 간격을 보장하지 않으며 Node.js 공식 문서도 타이머 콜백의 정확한 실행 시각을 보장하지 않는다고 명시한다.</p>
<p>주기 갱신이 실패하거나 예외를 던지면 <code>lost</code> 플래그를 설정한다. 다만 이미 시작한 <code>work()</code>를 즉시 취소하지는 않는다. 외부 작업이 반환되면 진행 중인 갱신을 기다린 뒤 소유권 상실을 오류로 처리한다.</p>
<p>주기 갱신은 작업 중 lease를 연장하지만, 늦은 완료를 거절하는 최종 조건은 DB UPDATE에 남아 있어야 한다. 워커가 갱신에 성공한 직후라도 다음 쓰기까지 소유권이 유지된다고 가정할 수 없다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="db-결과와-파일-쓰기의-서로-다른-보호-범위">DB 결과와 파일 쓰기의 서로 다른 보호 범위<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#db-%EA%B2%B0%EA%B3%BC%EC%99%80-%ED%8C%8C%EC%9D%BC-%EC%93%B0%EA%B8%B0%EC%9D%98-%EC%84%9C%EB%A1%9C-%EB%8B%A4%EB%A5%B8-%EB%B3%B4%ED%98%B8-%EB%B2%94%EC%9C%84" class="hash-link" aria-label="DB 결과와 파일 쓰기의 서로 다른 보호 범위에 대한 직접 링크" title="DB 결과와 파일 쓰기의 서로 다른 보호 범위에 대한 직접 링크" translate="no">​</a></h2>
<p>DB가 A의 완료를 거절해도 A가 이미 수행한 파일 쓰기까지 되돌아가지는 않는다. 검토한 워커는 미러 파일을 쓸 때 소유자 토큰을 <code>generation</code>으로 넘기며, 미러 구현은 세대별 파일 경로를 사용한다. A와 B가 같은 결과 파일명을 공유하지 않도록 나누고 DB에는 완료 조건을 통과한 결과의 경로를 기록하는 구조다.</p>
<p>고아 파일 정리도 늦은 워커 자신의 기억만으로 결정하지 않는다. <code>cleanupOrphansFenced()</code>는 행 잠금을 유지한 채 현재 DB 행을 정리 콜백에 전달한다. 워커는 DB가 가리키는 결과 경로와 현재 추출 중인 소유자의 경로를 보존 대상으로 지정한다.</p>
<p>여기에는 주의할 구현 세부도 있다. <code>processOne()</code>은 <code>complete()</code>가 반환한 불리언을 분기 조건으로 사용하지 않고 정리 단계로 이어간다. 따라서 <code>processOne()</code>의 <code>true</code>를 해당 워커의 결과가 DB에 채택됐다는 뜻으로 읽으면 안 된다.</p>
<p>세대별 경로와 정리 시 행 잠금이 있다고 해서 파일 시스템 작업 전체가 DB 트랜잭션에 포함되는 것은 아니다. 오래된 워커가 뒤늦게 파일을 쓰거나 파일 정리와 쓰기가 엇갈리는 상황은 별도 검증이 필요하다. lease만으로 외부 작업의 정확히 한 번 실행을 보장한다고 말할 수 없는 이유다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="이번-검토의-근거와-남은-검증">이번 검토의 근거와 남은 검증<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#%EC%9D%B4%EB%B2%88-%EA%B2%80%ED%86%A0%EC%9D%98-%EA%B7%BC%EA%B1%B0%EC%99%80-%EB%82%A8%EC%9D%80-%EA%B2%80%EC%A6%9D" class="hash-link" aria-label="이번 검토의 근거와 남은 검증에 대한 직접 링크" title="이번 검토의 근거와 남은 검증에 대한 직접 링크" translate="no">​</a></h2>
<p>제품의 lease 저장소, 워커, 미러 구현과 관련 단위 테스트 소스를 읽고 앞의 설명용 모델을 실행했다. 저장소 테스트는 가짜 쿼리 빌더로 잠금 설정 호출과 소유권 조건을 확인한다. 워커 테스트에는 A를 멈춘 사이 B가 완료한 뒤 A를 재개하고, B의 결과와 실행 중인 C의 파일을 보존하는 시나리오도 있다.</p>
<p>이 테스트의 저장소와 파일은 대역이며 실제 DB나 파일 시스템이 아니다. 이번 작성 환경에는 제품의 Jest 실행 파일이 없어 해당 단위 테스트는 실행하지 않았다. 따라서 직접 실행해 확인한 결과는 앞의 Python 모델에 한정된다.</p>
<p>실제 검증에서는 별도 DB 연결로 점유 경쟁을 만들고, 오래 멈춘 워커를 재개한 뒤 완료 UPDATE의 변경 행 수와 저장된 결과 경로를 함께 확인해야 한다. 공유 파일 시스템에서 쓰기와 정리가 겹칠 때 남는 파일도 확인 대상이다. 이 글은 그 실험의 성공을 주장하지 않는다.</p>
<p>작업을 다시 맡기는 조건과 결과를 받아들이는 조건은 분리해 확인해야 한다. lease 만료는 재점유를 허용하고, 현재 소유자와 상태를 확인하는 UPDATE는 늦게 돌아온 결과의 채택 여부를 결정한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/extraction-worker-lease-fencing#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE" target="_blank" rel="noopener noreferrer" class="">PostgreSQL SELECT의 잠금 절</a></li>
<li class=""><a href="https://nodejs.org/api/timers.html#settimeoutcallback-delay-args" target="_blank" rel="noopener noreferrer" class="">Node.js setTimeout의 실행 시각</a></li>
</ul>]]></content:encoded>
            <category>Architecture</category>
            <category>Decision Log</category>
        </item>
        <item>
            <title><![CDATA[Oracle 26ai에서 19c로, 요구사항 변경에 따른 전환 검토와 실험 설계]]></title>
            <link>https://tech.brain-crew.com/product/oracle-version-change-decision</link>
            <guid>https://tech.brain-crew.com/product/oracle-version-change-decision</guid>
            <pubDate>Fri, 11 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[고객의 DB 버전 요구사항이 바뀌었을 때 Deep Connector의 JSON 저장, CLOB 검색, 애플리케이션 반환 처리를 어떻게 검토했는지 정리한다. 파일 규모 계측과 용량 추정, 아직 실행하지 않은 비교 시험을 구분한 의사결정 기록이다.]]></description>
            <content:encoded><![CDATA[<p>Deep Connector를 구축하던 중 고객의 데이터베이스 버전 요구사항이 Oracle 26ai에서 19c로 바뀌었다.
기존 구현이 26ai의 네이티브 JSON을 사용하고 있었으므로, 변경 요청을 받았다고 접속 대상만 바꿀 수는 없었다.
DDL, 저장과 조회 코드, 검색 인덱스, 이관 절차를 함께 검토해야 했다.</p>
<blockquote>
<p>이 글은 전환 영향검토서와 성능 비교 쿼리 샘플을 함께 읽고 재구성한 기록이다.
자료에 남은 파일 규모 계측과 정적 검토, 용량 추정, 비교 시험 계획을 구분했다.
Oracle 26ai와 19c의 비교 부하 시험 결과나 최종 운영 전환 승인을 보고하는 글은 아니다.
고객사, 실제 계정과 스키마 식별자, 내부 작업 날짜 및 배포 정보는 제외했다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="버전-선택과-전환-승인의-분리">버전 선택과 전환 승인의 분리<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#%EB%B2%84%EC%A0%84-%EC%84%A0%ED%83%9D%EA%B3%BC-%EC%A0%84%ED%99%98-%EC%8A%B9%EC%9D%B8%EC%9D%98-%EB%B6%84%EB%A6%AC" class="hash-link" aria-label="버전 선택과 전환 승인의 분리에 대한 직접 링크" title="버전 선택과 전환 승인의 분리에 대한 직접 링크" translate="no">​</a></h2>
<p>영향검토서는 외부 요구가 없다면 26ai를 유지하는 편이 변경 위험이 작다고 판단했다.
이미 26ai를 기준으로 구현한 시스템을 바꿀 때의 회귀 위험을 평가한 것이지, Oracle 19c 자체가 불안정하다는 결론은 아니었다.
고객의 요구사항이 바뀌면 검토의 초점도 달라진다.
19c에서 필요한 기능을 유지하려면 무엇을 고쳐야 하는지, 어떤 시험을 통과해야 운영에 사용할 수 있는지를 정해야 한다.</p>
<p>기존 26ai DDL에는 빈 스키마 적용과 내부 일관성 검증을 통과한 기록이 있었다.
그 기록은 19c 호환성이나 실제 부하에서의 응답시간을 보장하지 않는다.
요구사항으로 목표 버전이 정해지는 것과 시험 결과로 전환을 승인하는 것은 별도 판단이다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="계측한-파일-크기와-아직-모르는-db-성능">계측한 파일 크기와 아직 모르는 DB 성능<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#%EA%B3%84%EC%B8%A1%ED%95%9C-%ED%8C%8C%EC%9D%BC-%ED%81%AC%EA%B8%B0%EC%99%80-%EC%95%84%EC%A7%81-%EB%AA%A8%EB%A5%B4%EB%8A%94-db-%EC%84%B1%EB%8A%A5" class="hash-link" aria-label="계측한 파일 크기와 아직 모르는 DB 성능에 대한 직접 링크" title="계측한 파일 크기와 아직 모르는 DB 성능에 대한 직접 링크" translate="no">​</a></h2>
<p>두 자료를 읽을 때 먼저 구분해야 할 것은 숫자의 출처였다.</p>






























<table><thead><tr><th>구분</th><th>자료에 남은 근거</th><th>해석 가능한 범위</th></tr></thead><tbody><tr><td>파일 기반 계측</td><td>원본 행과 고유 코드 수, JSON 직렬화 크기와 분포</td><td>입력 데이터의 구조와 논리적 크기</td></tr><tr><td>용량 추정</td><td>같은 상품 구성과 문서 재사용률, unit별 metadata 복제를 가정한 환산</td><td>가정이 유지될 때의 행 수와 JSON 원문량</td></tr><tr><td>정적 검토</td><td>DDL, 애플리케이션 저장 경로, 비교 SQL의 대조</td><td>시험 대상과 구현의 일치 여부</td></tr><tr><td>비교 시험 계획</td><td>조회, 저장, 검색, LOB fetch별 SQL과 측정 절차</td><td>앞으로 검증할 조건과 지표</td></tr></tbody></table>
<p>파일 계측에서는 고유 상품 하나가 매핑 한 행과 같지 않다는 점이 드러났다.
문서가 검색용 unit으로 나뉘고 metadata가 unit마다 복제된다면 DB의 물리 행 수는 그만큼 늘어난다.
따라서 원본 건수만으로 용량이나 응답시간을 판단할 수 없다.</p>
<p>용량 추정에는 상품 구성과 문서 재사용률이 유지된다는 가정이 들어갔다.
unit별 복제 시나리오도 전체 metadata를 각 unit에 저장한다는 보수적 가정이었다.
이 글에서는 고객 데이터의 정확한 건수와 용량을 공개하지 않으며, 다른 숫자로 바꿔 실측 예시처럼 제시하지도 않는다.</p>
<p>UTF-8 JSON 원문 크기를 UTF-16LE로 환산한 값 역시 실제 Oracle CLOB segment 크기는 아니다.
본문 CLOB, 리비전, 인덱스, 로그와 블록 여유 공간은 별도로 고려해야 한다.
자료에 따르면 파일을 직렬화해 크기를 재는 실험은 수행됐지만, 그 결과가 버전별 쿼리 속도를 뜻하지는 않는다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="json-저장-타입-변경과-애플리케이션-호환성">JSON 저장 타입 변경과 애플리케이션 호환성<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#json-%EC%A0%80%EC%9E%A5-%ED%83%80%EC%9E%85-%EB%B3%80%EA%B2%BD%EA%B3%BC-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%ED%98%B8%ED%99%98%EC%84%B1" class="hash-link" aria-label="JSON 저장 타입 변경과 애플리케이션 호환성에 대한 직접 링크" title="JSON 저장 타입 변경과 애플리케이션 호환성에 대한 직접 링크" translate="no">​</a></h2>
<p>이번 비교 대상은 Oracle RDB의 JSON 저장과 CLOB 본문 검색이다.
이 구성에서 임베딩 저장과 벡터 검색은 Qdrant가 담당하므로 Oracle 벡터 기능의 버전 비교는 범위에서 제외했다.</p>
<p>Oracle 26ai의 네이티브 <code>JSON</code> 타입은 OSON 바이너리 형식을 사용해 텍스트 JSON의 반복 파싱을 피한다.
19c 전환 기준안은 JSON을 <code>CLOB</code>에 저장하고 <code>IS JSON</code> 제약을 적용하는 방식이었다.
Oracle 공식 문서는 LOB JSON 저장에 <code>BLOB</code>을 권장하지만, 영향검토서는 기존 CLOB 처리 코드의 재사용을 고려해 CLOB을 초기 후보로 두고 BLOB은 후속 비교 대상으로 남겼다.
CLOB이 가장 빠르다고 확인해서 선택한 안은 아니었다.</p>
<p>변경 범위를 셀 때도 기준을 구분해야 했다.
검토 대상 DDL에는 JSON 컬럼이 29개였고, 이후 애플리케이션 코드에서 확인한 범위는 18개 테이블의 32개 컬럼이었다.
두 숫자는 서로 다른 기준의 목록이며, 변경 명세와 회귀 시험 대상은 실제 전환할 코드에 맞춰 다시 대조해야 한다.</p>
<p>DDL만 바꾸면 ORM의 타입 매핑이나 기동 시 보정 DDL에서 네이티브 JSON 정의가 다시 사용될 수 있다.
쓰기 직렬화, 읽기 역직렬화, 빈 객체와 빈 배열의 기본값, SQL <code>NULL</code>과 JSON <code>null</code>의 의미를 함께 확인해야 한다.
특히 <code>RETURNING ... INTO</code>로 CLOB을 돌려받는 경로는 자료에서도 별도 실측이 필요한 항목이었다.
쿼리 샘플은 이 호환성 확인용 INSERT를 일반 저장 경로와 구분해 제시했다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="grep-검색에서-나눠-볼-비용">grep 검색에서 나눠 볼 비용<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#grep-%EA%B2%80%EC%83%89%EC%97%90%EC%84%9C-%EB%82%98%EB%88%A0-%EB%B3%BC-%EB%B9%84%EC%9A%A9" class="hash-link" aria-label="grep 검색에서 나눠 볼 비용에 대한 직접 링크" title="grep 검색에서 나눠 볼 비용에 대한 직접 링크" translate="no">​</a></h2>
<p>문서 grep은 metadata의 JSON 조건과 본문 CLOB의 정규식 검색을 함께 사용한다.
본문은 두 버전 모두 CLOB이고, 비교에서 주의할 변화는 metadata 저장 형식과 JSON 조건의 처리 비용이다.
반환 행 수가 적어도 조건을 평가한 후보 행 수까지 적다는 뜻은 아니다.</p>
<p>JSON 필터와 정규식의 비용을 고정된 배수로 곱해 응답시간을 예측해서는 안 된다.
실제 비용은 후보 행 수, 필터 선택도, 선택된 인덱스와 실행계획에 따라 확인해야 한다.
영향검토서가 인용한 벤더 벤치마크의 배수도 이 시스템의 26ai 대비 19c 성능 저하율로 옮겨 쓸 수 없다.</p>
<p>쿼리 샘플에는 JSON 조건이 없는 grep 대조군도 포함됐다.
다만 제시된 SQL끼리는 조인과 반환 컬럼에도 차이가 있어, 시간 차이를 전부 JSON 파싱 비용으로 해석하기 어렵다.
파싱 영향을 더 좁혀 비교하려면 조인, 반환 컬럼, 정렬과 바인드 조건을 맞추고 실제 스캔 행 수도 함께 기록해야 한다.
이는 샘플을 실행하기 전에 보완할 실험 조건이지, 파싱 병목을 확인한 결과가 아니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="실제-저장-경로를-닮은-비교-시험">실제 저장 경로를 닮은 비교 시험<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#%EC%8B%A4%EC%A0%9C-%EC%A0%80%EC%9E%A5-%EA%B2%BD%EB%A1%9C%EB%A5%BC-%EB%8B%AE%EC%9D%80-%EB%B9%84%EA%B5%90-%EC%8B%9C%ED%97%98" class="hash-link" aria-label="실제 저장 경로를 닮은 비교 시험에 대한 직접 링크" title="실제 저장 경로를 닮은 비교 시험에 대한 직접 링크" translate="no">​</a></h2>
<p>성능 비교 쿼리 샘플에서 중요한 수정은 부분 갱신 SQL을 실제 저장 방식에 맞춘 것이었다.
애플리케이션은 최종 metadata 객체를 만든 뒤 전체 값을 다시 저장했으므로, <code>JSON_MERGEPATCH</code>만 비교하면 다른 동작을 시험하게 된다.
샘플은 이 부분 갱신을 제외하고 metadata 전체 저장과 본문을 함께 갱신하는 경로를 구분했다.</p>








































<table><thead><tr><th>시험 축</th><th>비교 조건</th><th>확인할 차이</th></tr></thead><tbody><tr><td>조회 범위</td><td>특정 문서와 컬렉션 내 문서 미특정</td><td>후보 행 수와 접근 경로</td></tr><tr><td>반환량</td><td>한 행과 Bulk 조회</td><td>정렬, 전송량과 LOB 읽기</td></tr><tr><td>검색 조건</td><td>JSON 조건 유무, 고정 경로와 사용자 지정 키</td><td>JSON 평가와 인덱스 사용</td></tr><tr><td>저장</td><td>단건과 500행 단위 배치</td><td>bind, 쓰기량과 왕복 비용</td></tr><tr><td>커밋</td><td>배치마다 커밋과 전체 배치 후 커밋</td><td>execute 시간과 commit 시간</td></tr><tr><td>응답 처리</td><td>경량 컬럼과 본문 및 metadata 포함</td><td>DB 실행 이후 fetch와 역직렬화</td></tr></tbody></table>
<p>문서를 특정하지 않는 조회도 컬렉션 조건은 유지하도록 설계했다.
실제 API와 다른 전체 테이블 조회로 부하를 키우지 않기 위해서다.
컬렉션 범위 Bulk UPDATE는 실제 서비스 경로가 아닌 비교 전용 SQL이며, 성능시험 전용 DB에서 실행하고 매회 롤백하는 조건이다.
커밋 비용을 재는 시험은 운영 데이터가 없는 별도 시험 스키마에서 수행해야 한다.</p>
<p>500행 배치는 애플리케이션이 ORM에 전달하는 저장 단위를 반영한다.
시험 도구의 array binding이나 <code>executemany</code>가 실제 ORM의 SQL 생성과 네트워크 왕복을 그대로 재현한다는 뜻은 아니다.
DB 단위 시험 뒤에 실제 드라이버와 ORM을 거치는 요청도 측정해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="인덱스-기준선과-최적화안의-분리">인덱스 기준선과 최적화안의 분리<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EA%B8%B0%EC%A4%80%EC%84%A0%EA%B3%BC-%EC%B5%9C%EC%A0%81%ED%99%94%EC%95%88%EC%9D%98-%EB%B6%84%EB%A6%AC" class="hash-link" aria-label="인덱스 기준선과 최적화안의 분리에 대한 직접 링크" title="인덱스 기준선과 최적화안의 분리에 대한 직접 링크" translate="no">​</a></h2>
<p>DDL에 JSON Search Index가 없다는 사실만으로 실제 DB에도 없다고 단정할 수 없었다.
애플리케이션의 기동 보정 로직이 인덱스 생성을 시도하고 실패하면 일반 JSON 조건 조회로 폴백했기 때문이다.
시험 전에 실제 인덱스의 존재와 상태를 확인해야 하는 이유다.</p>
<p>먼저 같은 인덱스 조건에서 버전별 기준선을 측정하고, 함수 기반 인덱스를 추가한 최적화안은 별도 결과로 기록하도록 설계했다.
한쪽에만 인덱스를 추가하고 버전 차이라고 보고하면 개선 원인을 구분할 수 없다.
고정 JSON 경로는 함수 기반 인덱스의 후보가 되지만, 임의 metadata 키 전체를 미리 같은 방식으로 인덱싱하기는 어렵다.
Oracle 공식 문서도 인덱스 이름이 실제 실행계획에 나타나는지 확인하도록 안내한다.</p>
<p>검색 인덱스는 읽기 지연만으로 평가할 수 없다.
동기화 방식에 따른 쓰기와 커밋 비용, 갱신한 데이터가 검색에 반영되는 지연도 함께 비교해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="db-실행시간-이후까지-이어지는-측정">DB 실행시간 이후까지 이어지는 측정<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#db-%EC%8B%A4%ED%96%89%EC%8B%9C%EA%B0%84-%EC%9D%B4%ED%9B%84%EA%B9%8C%EC%A7%80-%EC%9D%B4%EC%96%B4%EC%A7%80%EB%8A%94-%EC%B8%A1%EC%A0%95" class="hash-link" aria-label="DB 실행시간 이후까지 이어지는 측정에 대한 직접 링크" title="DB 실행시간 이후까지 이어지는 측정에 대한 직접 링크" translate="no">​</a></h2>
<p>시험 계획은 동일 데이터 스냅샷과 하드웨어, 문자셋, 통계, 커넥션 풀 및 동시성을 맞추는 것을 전제로 했다.
쿼리 샘플은 각 케이스를 3~5회 warm-up한 뒤 최소 30회 측정하고 평균과 P50, P95, P99를 기록하도록 제안했다.
이 반복 횟수는 실행 기록이 아니라 계획이며, 특히 P99를 안정적으로 판단하려면 충분한 표본과 반복 부하를 추가로 확보해야 한다.</p>
<p>측정은 DB가 첫 결과를 돌려준 시점에서 끝나지 않는다.
CLOB을 끝까지 읽고 JSON을 역직렬화한 뒤 응답을 만들 때까지의 시간과 커넥션 점유시간을 기록해야 한다.
DB CPU와 읽기량, 반환 바이트, 애플리케이션 CPU와 메모리를 함께 보면 지연이 늘어난 구간을 좁힐 수 있다.</p>
<p>영향검토서에는 읽기 API P95 증가를 26ai 대비 20% 이내로 제한하고, 검색 API P99 증가는 30% 이내로 제한하는 초기 제안이 있었다.
업로드와 파싱 처리량은 26ai의 90% 이상을 제안했다.
이 수치는 고객과 합의한 SLA도, 시험을 통과한 결과도 아니다.
실제 승인에는 성능 기준 합의와 함께 기능 정합성, 검색 최신성, 삭제 시 잠금, 이관시간과 롤백 검증이 필요하다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="개발-완료-이후의-전환-판단">개발 완료 이후의 전환 판단<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#%EA%B0%9C%EB%B0%9C-%EC%99%84%EB%A3%8C-%EC%9D%B4%ED%9B%84%EC%9D%98-%EC%A0%84%ED%99%98-%ED%8C%90%EB%8B%A8" class="hash-link" aria-label="개발 완료 이후의 전환 판단에 대한 직접 링크" title="개발 완료 이후의 전환 판단에 대한 직접 링크" translate="no">​</a></h2>
<p>영향검토서는 개발 공수를 9~15영업일로 산정했지만, 전체 성능 시험과 운영 전환 리허설, DBA 반입 및 컷오버 일정은 별도로 분리했다.
개발 범위에 일부 실측 확인이 포함돼 있어도 전체 전환 승인에 필요한 검증을 대신하지는 않는다.</p>
<p>이관 계획의 <code>expdp VERSION=19</code>도 실행 성공을 뜻하지 않는다.
자료는 네이티브 JSON 컬럼 처리 여부를 시험 이관으로 확인하고, 필요하면 별도 직렬화 경로를 검토하도록 남겼다.
롤백 역시 접속 대상을 원래 DB로 되돌리는 것으로 끝나지 않는다.
19c에서 새로 발생한 쓰기를 어떻게 원본에 반영할지 정하지 않으면 복귀 과정에서 데이터가 유실될 수 있다.</p>
<p>이번 검토에서 확보한 것은 전환 성공의 증명이 아니라, 바뀐 요구사항에 맞춰 확인할 변경 범위와 시험 조건이었다.
파일 계측은 데이터 구조를 설명하고, 용량 추정은 준비할 시험 규모를 정하는 데 쓰이며, 비교 쿼리는 아직 모르는 성능을 확인하는 수단이다.
19c 채택 요구를 실제 운영 승인으로 이어가려면 그 사이에 남은 호환성, 부하, 이관과 복구 시험 결과가 필요하다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/oracle-version-change-decision#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/26/adjsn/json-data-type.html" target="_blank" rel="noopener noreferrer" class="">Oracle 26ai JSON Data Type</a></li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/adjsn/considerations-when-using-lob-storage-json-data.html" target="_blank" rel="noopener noreferrer" class="">Oracle 19c Considerations When Using LOB Storage for JSON Data</a></li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/adjsn/indexes-for-json-data.html" target="_blank" rel="noopener noreferrer" class="">Oracle 19c Indexes for JSON Data</a></li>
</ul>]]></content:encoded>
            <category>deep-connector</category>
            <category>oracle</category>
            <category>database</category>
            <category>on-premise</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[동의어 검색에서도 사용자가 입력한 단어를 먼저 찾기]]></title>
            <link>https://tech.brain-crew.com/product/query-expansion-user-intent</link>
            <guid>https://tech.brain-crew.com/product/query-expansion-user-intent</guid>
            <pubDate>Fri, 11 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[DeepConnector 파일 검색은 사용자 입력과 사전 확장어를 함께 검색하되 서로 다른 가중치로 평가한다. 실제 스니펫 조립 코드를 설명용 데이터로 실행해 순위를 확인하고, 입력 상한과 확장 실패 처리의 경계를 짚었다.]]></description>
            <content:encoded><![CDATA[<p>문서 코드를 정확히 입력했는데, 그 코드가 없는 안내문이 먼저 나온다고 하자. 검색 사전이 붙인 동의어가 안내문에 더 많이 등장했기 때문이다. 찾을 수 있는 표현은 늘었지만 사용자가 직접 지정한 단어의 우선순위는 낮아진 셈이다.</p>
<p>DeepConnector의 파일 검색은 사용자 입력과 사전 확장어를 함께 검색하되 점수는 구분한다. 직접 입력한 단어의 일치에는 큰 가중치를 주고, 사전이 보탠 별칭의 일치에는 작은 가중치를 준다. 동의어로 검색 범위를 넓히면서도 원래 검색어를 우선하려는 설계다.</p>
<blockquote>
<p>첫 상황은 설계를 설명하기 위한 가정이며 실제 고객 장애가 아니다. 아래 문서와 검색어도 설명용으로 만들었다. 검증에는 특정 구현 스냅샷에서 추출한 실제 스니펫 조립 코드를 사용했고, 검색 서버나 사전 서비스 전체를 실행한 것은 아니다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="검색할-단어와-점수를-매길-단어의-구분">검색할 단어와 점수를 매길 단어의 구분<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#%EA%B2%80%EC%83%89%ED%95%A0-%EB%8B%A8%EC%96%B4%EC%99%80-%EC%A0%90%EC%88%98%EB%A5%BC-%EB%A7%A4%EA%B8%B8-%EB%8B%A8%EC%96%B4%EC%9D%98-%EA%B5%AC%EB%B6%84" class="hash-link" aria-label="검색할 단어와 점수를 매길 단어의 구분에 대한 직접 링크" title="검색할 단어와 점수를 매길 단어의 구분에 대한 직접 링크" translate="no">​</a></h2>
<p>설명용 문서 코드 <code>SETUP01</code>에 사전이 <code>설치 안내</code>, <code>설치 설명서</code>, <code>셋업 가이드</code>를 연결한다고 가정했다. 코드만 검색하면 코드를 적지 않은 안내문은 찾지 못할 수 있다. 별칭도 함께 검색하면 그런 문서를 찾을 여지가 생긴다.</p>
<p>문제는 검색할 단어를 합치는 순간 그 단어의 출처를 잊는 데 있다. 모두 같은 무게로 세면 사용자가 입력한 코드 하나보다 별칭 여러 개가 더 높은 점수를 받을 수 있다. 그래서 파일 검색 서비스는 합쳐진 검색어 목록과 별도로, 사용자가 입력하지 않은 확장어 집합을 유지한다.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// 제품의 검색어 분리 로직을 옮긴 설명용 코드</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> searchTerms </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token spread operator" style="color:#393A34">...</span><span class="token keyword" style="color:#00009f">new</span><span class="token plain"> </span><span class="token class-name">Set</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">[</span><span class="token spread operator" style="color:#393A34">...</span><span class="token plain">userTerms</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token spread operator" style="color:#393A34">...</span><span class="token plain">expandedTerms</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> expansionTerms </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">new</span><span class="token plain"> </span><span class="token class-name">Set</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  expandedTerms</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">filter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">term</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!</span><span class="token plain">userTerms</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">includes</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">term</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p><code>searchTerms</code>는 실제 문자열 검색에 넘길 목록이다. <code>expansionTerms</code>는 일치한 단어에 확장어 가중치를 적용할지 판단할 때 쓴다. 사전이 사용자 입력과 똑같은 문자열을 반환해도 검색 목록에는 한 번만 남고, 확장 전용 집합에서는 빠진다. 직접 입력한 단어가 사전에도 있다는 이유로 가중치가 낮아지지 않는 구조다.</p>
<p>여기서 중복 제거는 문자열 값의 비교다. <code>Set</code>이 의미가 비슷한 단어를 알아서 합쳐 주는 것은 아니며, 이 코드가 대소문자나 형태소까지 정규화하는 것도 아니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="원래-검색어에-더-큰-가중치">원래 검색어에 더 큰 가중치<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#%EC%9B%90%EB%9E%98-%EA%B2%80%EC%83%89%EC%96%B4%EC%97%90-%EB%8D%94-%ED%81%B0-%EA%B0%80%EC%A4%91%EC%B9%98" class="hash-link" aria-label="원래 검색어에 더 큰 가중치에 대한 직접 링크" title="원래 검색어에 더 큰 가중치에 대한 직접 링크" translate="no">​</a></h2>
<p>스니펫은 검색 결과에서 본문 일부를 잘라 보여주는 단위다. <code>SnippetAssembler</code>는 스니펫에 일치한 단어를 중복 없이 모은 뒤 사용자 입력과 확장 전용 일치로 나눠 <code>lexicalScore</code>를 계산한다. 같은 단어의 반복 등장 횟수를 그대로 사용자 일치 개수로 세지는 않는다.</p>
<p>확장어 상한은 <code>MAX_EXPANSION_TERMS = 32</code>이고, 확장어 하나의 가중치는 <code>1 / (MAX_EXPANSION_TERMS + 1)</code>이다. 사용자 일치에는 단어당 <code>1</code>을 더한다. 실제 점수식에는 하이라이트 개수에 따른 보정도 포함된다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">사용자 일치 개수</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">+ 확장 전용 일치 개수 × 확장어 가중치</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">+ (하이라이트 개수 / 100) × 확장어 가중치</span><br></span></code></pre></div></div>
<p>확장어 일치 항만 보면 상한까지 채워도 사용자 일치 하나보다 작다. 다만 실제 점수에는 하이라이트 보정이 더해지므로, 이 비교만으로 모든 입력에서 순위가 보장된다고 결론 내리면 안 된다. 반복 일치나 하이라이트가 많은 경우까지 보장하려면 별도의 경계 검증이 필요하다.</p>
<p>파일 검색 결과는 <code>lexicalScore</code> 내림차순으로 정렬한다. 점수가 같으면 <code>priority</code> 오름차순, 문서 날짜 내림차순, <code>fileId</code> 사전순으로 비교한다. 이 글의 점수는 파일 검색의 스니펫 점수이며 벡터 검색이나 모든 검색 모드의 통합 순위를 뜻하지 않는다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="실제-조립-코드로-확인한-순위">실제 조립 코드로 확인한 순위<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#%EC%8B%A4%EC%A0%9C-%EC%A1%B0%EB%A6%BD-%EC%BD%94%EB%93%9C%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%9C-%EC%88%9C%EC%9C%84" class="hash-link" aria-label="실제 조립 코드로 확인한 순위에 대한 직접 링크" title="실제 조립 코드로 확인한 순위에 대한 직접 링크" translate="no">​</a></h2>
<p>실행 예시에서는 검색 엔진이 반환할 일치 위치를 직접 만들고 실제 <code>SnippetAssembler.assemble()</code>에 넣었다. 문서마다 명시적인 본문 구간을 지정해 일치 항목들이 한 스니펫으로 묶이도록 했다. 사전 확장 결과도 고정했으므로, 이 실행이 검증하는 것은 동의어의 적절성이나 검색 엔진의 탐지 능력이 아니라 일치 이후의 점수 계산이다.</p>




















<table><thead><tr><th>문서</th><th>설명용 본문</th><th>일치 유형</th></tr></thead><tbody><tr><td>A</td><td><code>SETUP01 설치 절차</code></td><td>사용자 입력 <code>SETUP01</code></td></tr><tr><td>B</td><td><code>설치 안내 / 설치 설명서 / 셋업 가이드</code></td><td>확장 전용 표현</td></tr></tbody></table>
<p>실행 결과에서 문서 A의 점수가 문서 B보다 높았다. 점수는 소수점 아래 여섯 자리로 반올림했다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">A  1.000303</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">B  0.091818</span><br></span></code></pre></div></div>
<p>확장어를 상한인 32개까지 채운 별도 입력도 확인했다. 서로 겹치지 않는 확장어가 한 번씩 일치하는 스니펫의 점수는 <code>0.979394</code>였고, 사용자 입력 하나가 일치한 A보다 낮았다. 두 비교는 실제 조립 코드로 실행한 결과이지, 사용자 만족도나 검색 정확도를 측정한 수치가 아니다.</p>
<p>저장소의 회귀 테스트도 사용자 입력 일치 문서를 별칭만 일치하는 문서보다 먼저 반환하는지 확인하도록 작성돼 있다. 이번 검증에서는 그 테스트의 코드와 서비스 연결을 읽고, 조립 코드를 분리 실행했다. 회귀 테스트 전체나 실제 ripgrep 프로세스까지 실행한 것으로 해석해서는 안 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="사전이-입력-제한을-바꾸지-않도록">사전이 입력 제한을 바꾸지 않도록<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#%EC%82%AC%EC%A0%84%EC%9D%B4-%EC%9E%85%EB%A0%A5-%EC%A0%9C%ED%95%9C%EC%9D%84-%EB%B0%94%EA%BE%B8%EC%A7%80-%EC%95%8A%EB%8F%84%EB%A1%9D" class="hash-link" aria-label="사전이 입력 제한을 바꾸지 않도록에 대한 직접 링크" title="사전이 입력 제한을 바꾸지 않도록에 대한 직접 링크" translate="no">​</a></h2>
<p>확장어를 추가하는 시점도 중요하다. 파일 검색 서비스는 사용자 입력을 먼저 검증한 뒤 사전 확장을 적용한다. 사용자가 보낼 수 있는 검색어는 최대 16개이며 이 상한에 사전 확장어는 포함하지 않는다.</p>
<p>사용자 입력 16개에 확장어 2개가 붙는다면 검색기에 넘길 목록은 중복이 없을 때 18개가 된다. 반대로 사용자가 처음부터 17개를 보내면 여전히 <code>SEARCH_INVALID_QUERY</code>로 거부한다. 사전을 켰다고 사용자가 입력할 수 있는 단어 수가 줄어서도, 입력 제한이 느슨해져서도 안 된다.</p>
<p>확장 실패도 원래 검색과 구분한다. 확장 서비스는 분석기나 사전 조회 실패를 빈 확장어 목록과 사유로 반환하는 계약을 갖고 있다. 파일 검색은 그 결과를 받으면 원래 검색어로 계속 진행한다. 파일 검색 호출부가 임의의 예외를 모두 삼키는 방식은 아니므로, 이 동작을 검색 중 어떤 오류가 나도 성공한다는 뜻으로 넓혀 읽으면 안 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="더-찾는-기능과-먼저-보여줄-기준">더 찾는 기능과 먼저 보여줄 기준<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#%EB%8D%94-%EC%B0%BE%EB%8A%94-%EA%B8%B0%EB%8A%A5%EA%B3%BC-%EB%A8%BC%EC%A0%80-%EB%B3%B4%EC%97%AC%EC%A4%84-%EA%B8%B0%EC%A4%80" class="hash-link" aria-label="더 찾는 기능과 먼저 보여줄 기준에 대한 직접 링크" title="더 찾는 기능과 먼저 보여줄 기준에 대한 직접 링크" translate="no">​</a></h2>
<p>동의어 사전은 사용자가 입력하지 않은 표현을 검색에 보탠다. 그만큼 결과를 정렬할 때는 직접 입력한 단어와 사전이 추가한 단어를 구분할 필요가 있다.</p>
<p>DeepConnector의 파일 검색에서는 그 구분이 확장 전용 집합과 스니펫 가중치로 이어진다. 사전의 단어 수만 늘리는 대신, 원래 검색어의 우선순위와 사용자 입력 상한, 확장이 없어도 계속할 수 있는 검색 경로를 함께 검토해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/query-expansion-user-intent#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set" target="_blank" rel="noopener noreferrer" class="">MDN의 JavaScript Set 문서</a></li>
</ul>]]></content:encoded>
            <category>Architecture</category>
            <category>Decision Log</category>
        </item>
        <item>
            <title><![CDATA[워크스페이스 루트 하나의 조건, PostgreSQL과 Oracle의 유니크 인덱스]]></title>
            <link>https://tech.brain-crew.com/product/workspace-root-uniqueness</link>
            <guid>https://tech.brain-crew.com/product/workspace-root-uniqueness</guid>
            <pubDate>Fri, 11 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[Deep Connector의 워크스페이스 루트 중복 방지를 PostgreSQL 부분 유니크 인덱스와 Oracle 함수 기반 유니크 인덱스로 구현한 구조를 설명한다. 최대 하나와 최소 하나의 차이, 최초 기동 시점, 외부 스키마 관리의 책임을 구분한다.]]></description>
            <content:encoded><![CDATA[<p>워크스페이스마다 루트 폴더가 하나 있어야 한다. 제품 요구사항으로는 짧지만, 데이터베이스에 옮기면 “둘 이상 생기지 않는다”와 “없는 상태를 채운다”를 따로 구현해야 한다.</p>
<p>Deep Connector는 루트 중복을 PostgreSQL의 부분 유니크 인덱스와 Oracle의 함수 기반 유니크 인덱스로 막는다. 인덱스가 보장하는 것은 최대 하나다. 루트를 생성하는 작업과 인덱스를 설치하는 작업까지 있어야 제품이 요구하는 상태를 만들 수 있다.</p>
<blockquote>
<p>이 글은 구현 코드와 공식 문서를 대조한 설계 설명이다. SQL은 식별자와 구조를 단순화한 개념 예시이며, 글 작성 과정에서 PostgreSQL이나 Oracle에 실행한 실험 결과가 아니다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="루트-식별과-중복-방지의-구분">루트 식별과 중복 방지의 구분<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#%EB%A3%A8%ED%8A%B8-%EC%8B%9D%EB%B3%84%EA%B3%BC-%EC%A4%91%EB%B3%B5-%EB%B0%A9%EC%A7%80%EC%9D%98-%EA%B5%AC%EB%B6%84" class="hash-link" aria-label="루트 식별과 중복 방지의 구분에 대한 직접 링크" title="루트 식별과 중복 방지의 구분에 대한 직접 링크" translate="no">​</a></h2>
<p>루트는 <code>depth = 0</code>인 폴더다. 엔티티의 교차 컬럼 <code>CHECK</code>는 루트의 <code>parent_id</code>가 <code>NULL</code>이어야 하고, 사용자 폴더는 깊이가 1부터 3까지이며 부모 값이 있어야 한다고 정한다. <code>workspace_id</code>와 <code>depth</code>에는 <code>NOT NULL</code>이 선언돼 있다.</p>
<p>이 조건만으로 루트 중복을 막지는 못한다. 같은 워크스페이스에 <code>depth = 0</code>, <code>parent_id = NULL</code>인 행을 추가해도 각 행은 <code>CHECK</code>를 만족하기 때문이다. 반대로 <code>workspace_id</code> 전체에 유니크를 걸면 사용자 폴더까지 워크스페이스당 하나로 제한된다.</p>
<p>필요한 조건은 루트 행만 골라서 <code>workspace_id</code>의 중복을 금지하는 것이다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="postgresql의-부분-유니크-인덱스">PostgreSQL의 부분 유니크 인덱스<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#postgresql%EC%9D%98-%EB%B6%80%EB%B6%84-%EC%9C%A0%EB%8B%88%ED%81%AC-%EC%9D%B8%EB%8D%B1%EC%8A%A4" class="hash-link" aria-label="PostgreSQL의 부분 유니크 인덱스에 대한 직접 링크" title="PostgreSQL의 부분 유니크 인덱스에 대한 직접 링크" translate="no">​</a></h2>
<p><a href="https://www.postgresql.org/docs/current/indexes-partial.html" target="_blank" rel="noopener noreferrer" class="">PostgreSQL의 부분 인덱스</a>는 조건에 맞는 행만 인덱스에 포함한다. 여기에 <code>UNIQUE</code>를 붙이면 그 행들 사이에서만 중복을 막는다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">PostgreSQL 개념 예시</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">CREATE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">UNIQUE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INDEX</span><span class="token plain"> uq_folder_root</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> folder </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">workspace_id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> depth </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p><code>folder</code>는 설명용 테이블 이름이며, <code>workspace_id</code>가 <code>NOT NULL</code>이라는 전제다. 같은 워크스페이스의 루트 행은 동일한 키를 사용하므로 중복될 수 없고, <code>depth &gt; 0</code>인 폴더는 이 인덱스의 제한을 받지 않는다. 다른 워크스페이스의 루트는 키가 다르므로 함께 존재할 수 있다.</p>
<p>실제 PostgreSQL 마이그레이션은 기존 루트 인덱스를 삭제하고, <code>workspace_id</code>에 <code>WHERE depth = 0</code> 조건을 붙인 부분 유니크 인덱스를 만든 뒤, 루트 표시용 중복 컬럼을 제거한다. 이 마이그레이션 자체가 없는 루트를 생성하는 것은 아니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="oracle의-함수-기반-유니크-인덱스">Oracle의 함수 기반 유니크 인덱스<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#oracle%EC%9D%98-%ED%95%A8%EC%88%98-%EA%B8%B0%EB%B0%98-%EC%9C%A0%EB%8B%88%ED%81%AC-%EC%9D%B8%EB%8D%B1%EC%8A%A4" class="hash-link" aria-label="Oracle의 함수 기반 유니크 인덱스에 대한 직접 링크" title="Oracle의 함수 기반 유니크 인덱스에 대한 직접 링크" translate="no">​</a></h2>
<p>Oracle 구현은 같은 중복 방지 조건을 표현식으로 옮긴다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Oracle 개념 예시</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">CREATE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">UNIQUE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">INDEX</span><span class="token plain"> uq_folder_root</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">ON</span><span class="token plain"> folder </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">CASE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">WHEN</span><span class="token plain"> depth </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">THEN</span><span class="token plain"> workspace_id </span><span class="token keyword" style="color:#00009f">END</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>루트 행에서는 <code>CASE</code>가 <code>workspace_id</code>를 반환하고, 나머지 행에서는 <code>NULL</code>을 반환한다. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html" target="_blank" rel="noopener noreferrer" class="">Oracle의 일반 B-tree 인덱스</a>는 키 전체가 <code>NULL</code>인 행을 저장하지 않는다. 이 예시는 표현식 하나만 인덱스 키로 쓰므로 루트가 아닌 행은 인덱스에서 빠진다.</p>
<p>결과적으로 루트 행끼리만 워크스페이스 키를 비교한다. 여기서도 <code>workspace_id</code>의 <code>NOT NULL</code>이 중요하다. 루트의 워크스페이스 값까지 <code>NULL</code>을 허용하면 그 행도 키가 없는 행으로 빠져 중복 방지 대상에서 제외된다.</p>
<p>두 SQL은 같은 업무 규칙을 표현하지만, 설치 과정까지 같은 것은 아니다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="최초-테이블-생성-뒤의-인덱스-설치">최초 테이블 생성 뒤의 인덱스 설치<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#%EC%B5%9C%EC%B4%88-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%83%9D%EC%84%B1-%EB%92%A4%EC%9D%98-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EC%B9%98" class="hash-link" aria-label="최초 테이블 생성 뒤의 인덱스 설치에 대한 직접 링크" title="최초 테이블 생성 뒤의 인덱스 설치에 대한 직접 링크" translate="no">​</a></h2>
<p>Oracle 구현은 기존 스키마를 정리하는 시작 업그레이드와 루트 인덱스 생성을 나눈다. 시작 업그레이드는 예전 루트 표시 컬럼을 정리하고, 없는 루트를 시드하며, 깊이와 부모의 <code>CHECK</code>를 맞춘다. 루트의 함수 기반 유니크 인덱스 생성은 별도 부트스트랩 서비스가 맡는다.</p>
<p>이 구분은 빈 스키마를 처음 만드는 경로에서 필요하다. 명시적으로 동기화를 요청한 빈 Oracle 스키마에서는 TypeORM의 <code>synchronize</code>를 허용하고 시작 업그레이드는 건너뛴다. 시작 업그레이드가 먼저 테이블을 만들면 빈 스키마라는 전제를 바꿔 버리기 때문이다.</p>
<p>그래서 인덱스를 시작 업그레이드에만 넣으면 최초 동기화 경로에서 빠질 수 있다. 구현은 <code>OnApplicationBootstrap</code>에서 테이블 존재 여부를 확인한 뒤 인덱스를 생성하도록 배치한다. <a href="https://docs.nestjs.com/fundamentals/lifecycle-events" target="_blank" rel="noopener noreferrer" class="">NestJS의 이 훅</a>은 모든 모듈이 초기화된 뒤, 연결을 수신하기 전에 실행된다.</p>
<p>다만 테이블 존재 확인은 스키마 전체 검증이 아니다. 테이블이 없으면 이 서비스는 인덱스 생성도 건너뛴다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="재기동-허용과-인덱스-검증의-차이">재기동 허용과 인덱스 검증의 차이<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#%EC%9E%AC%EA%B8%B0%EB%8F%99-%ED%97%88%EC%9A%A9%EA%B3%BC-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EA%B2%80%EC%A6%9D%EC%9D%98-%EC%B0%A8%EC%9D%B4" class="hash-link" aria-label="재기동 허용과 인덱스 검증의 차이에 대한 직접 링크" title="재기동 허용과 인덱스 검증의 차이에 대한 직접 링크" translate="no">​</a></h2>
<p>부트스트랩 서비스는 생성 중 <code>ORA-00955</code> 또는 <code>ORA-01408</code>을 받으면 오류를 다시 던지지 않는다. 공식 오류 설명에서 <a href="https://docs.oracle.com/error-help/db/ora-00955/" target="_blank" rel="noopener noreferrer" class="">ORA-00955</a>는 이미 사용 중인 객체 이름, <a href="https://docs.oracle.com/error-help/db/ora-01408/" target="_blank" rel="noopener noreferrer" class="">ORA-01408</a>은 이미 인덱스가 있는 컬럼 목록을 뜻한다.</p>
<p>이 처리는 재기동 때 이미 있는 인덱스를 다시 만들려는 시도를 허용하지만, 원하는 인덱스가 있다는 증거는 아니다. 서비스는 이후 카탈로그를 조회해 인덱스의 표현식이나 유니크 속성을 다시 검사하지 않는다. 특히 같은 이름이 남아 있다는 사실만으로 새 루트 조건과 일치한다고 판단할 수 없다.</p>
<p>기존 중복 데이터를 자동으로 정리하는 코드도 이 서비스에는 없다. 따라서 “재실행해도 일부 중복 생성 오류를 무시한다”와 “어떤 기존 스키마에서도 올바른 인덱스로 복구한다”는 구분해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="외부-관리-스키마의-책임">외부 관리 스키마의 책임<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#%EC%99%B8%EB%B6%80-%EA%B4%80%EB%A6%AC-%EC%8A%A4%ED%82%A4%EB%A7%88%EC%9D%98-%EC%B1%85%EC%9E%84" class="hash-link" aria-label="외부 관리 스키마의 책임에 대한 직접 링크" title="외부 관리 스키마의 책임에 대한 직접 링크" translate="no">​</a></h2>
<p>DBA가 스키마를 관리하고 애플리케이션은 데이터만 읽고 쓰는 배포라면 인덱스 설치도 스키마 관리 절차에 포함해야 한다. Oracle 루트 인덱스 서비스는 외부 관리로 판정되면 카탈로그 조회와 인덱스 생성을 건너뛴다. 이때 애플리케이션이 만들지 않는다는 이유로 인덱스가 불필요해지는 것은 아니다.</p>
<p>서비스가 사용하는 <code>USER_TABLES</code>는 <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/USER_TABLES.html" target="_blank" rel="noopener noreferrer" class="">현재 사용자가 소유한 테이블</a>을 보여준다. 다른 계정의 테이블을 읽을 권한과 그 테이블을 소유한다는 사실은 다르므로, 조회 결과가 없다는 이유만으로 생성에 나서면 안 된다.</p>
<p>외부 관리 설정을 모든 시작 작업의 차단 스위치로 해석하는 것도 위험하다. 확인한 코드에서는 <code>TYPEORM_SYNCHRONIZE=true</code>를 명시하고 테이블 수 조회가 성공하면, 테이블이 있는 경우 시작 업그레이드 플래그를 다시 켜는 분기가 있다. 루트 인덱스 서비스의 외부 관리 가드는 그대로지만, 별도 시작 업그레이드의 DDL과 DML까지 무조건 제외된다고 말할 수는 없다.</p>
<p>외부 관리 배포에서는 동기화 설정과 시작 업그레이드 실행 여부를 함께 확인하고, 담당 관리 절차에서 루트 인덱스의 정의를 검증해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="최대-하나와-최소-하나의-별도-검증">최대 하나와 최소 하나의 별도 검증<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#%EC%B5%9C%EB%8C%80-%ED%95%98%EB%82%98%EC%99%80-%EC%B5%9C%EC%86%8C-%ED%95%98%EB%82%98%EC%9D%98-%EB%B3%84%EB%8F%84-%EA%B2%80%EC%A6%9D" class="hash-link" aria-label="최대 하나와 최소 하나의 별도 검증에 대한 직접 링크" title="최대 하나와 최소 하나의 별도 검증에 대한 직접 링크" translate="no">​</a></h2>
<p>유니크 인덱스는 루트가 없는 상태를 위반으로 보지 않는다. 없는 루트를 채우는 시드나 생성 로직이 따로 필요하고, 생성 시점에 존재했다는 사실만으로 이후에도 루트가 항상 남는다고 보장할 수는 없다.</p>
<p>배포 검증에서는 올바른 인덱스가 설치됐는지 확인한 뒤, 루트가 없는 워크스페이스도 별도로 조회해야 한다. 중복 루트 입력과 일반 폴더 입력, 루트 부재 상태를 나눠 확인하는 것은 이 규칙을 검증하기 위한 제안이며, 여기서 실행한 테스트 목록은 아니다.</p>
<p>워크스페이스 루트 하나라는 제품 규칙에는 중복 방지, 루트 생성, 스키마 설치 책임이 함께 들어 있다. SQL 문법을 데이터베이스마다 바꾸는 것만으로는 부족하고, 각 작업의 실행 조건까지 맞아야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/workspace-root-uniqueness#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://www.postgresql.org/docs/current/indexes-partial.html" target="_blank" rel="noopener noreferrer" class="">PostgreSQL 공식 문서, Partial Indexes</a></li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html" target="_blank" rel="noopener noreferrer" class="">Oracle Database 19c SQL Language Reference, CREATE INDEX</a></li>
<li class=""><a href="https://docs.nestjs.com/fundamentals/lifecycle-events" target="_blank" rel="noopener noreferrer" class="">NestJS 공식 문서, Lifecycle events</a></li>
<li class=""><a href="https://docs.oracle.com/error-help/db/ora-00955/" target="_blank" rel="noopener noreferrer" class="">Oracle 공식 오류 문서, ORA-00955</a></li>
<li class=""><a href="https://docs.oracle.com/error-help/db/ora-01408/" target="_blank" rel="noopener noreferrer" class="">Oracle 공식 오류 문서, ORA-01408</a></li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/USER_TABLES.html" target="_blank" rel="noopener noreferrer" class="">Oracle Database 19c Reference, USER_TABLES</a></li>
</ul>]]></content:encoded>
            <category>deep-connector</category>
            <category>postgresql</category>
            <category>oracle</category>
            <category>database</category>
        </item>
        <item>
            <title><![CDATA[테이블이 있는데 없다고 했다: Oracle 기동 오류를 좁혀간 기록]]></title>
            <link>https://tech.brain-crew.com/product/oracle-startup-schema-retrospective</link>
            <guid>https://tech.brain-crew.com/product/oracle-startup-schema-retrospective</guid>
            <pubDate>Tue, 08 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[Deep Connector를 온프레미스 환경에 옮기면서 반복된 ORA-01031을 추적했다. 식별자 대소문자, 스키마 소유권, 기동 DDL을 분리하고 실제 권한 구조를 재현하면서 배운 점을 정리한다.]]></description>
            <content:encoded><![CDATA[<p>애플리케이션은 테이블이 없다고 판단했다. 테이블을 만들려고 하자 Oracle은 권한이 없다고 응답했다. 그런데 테이블은 이미 있었다.</p>
<p>Deep Connector를 온프레미스 환경에 옮기던 중 만난 문제다. 오류를 수정해 이미지를 다시 만들었는데도 기동 로그에는 같은 <code>ORA-01031: insufficient privileges</code>가 남았다. 하나를 고칠 때마다 다른 조건이 드러났다. 결국 오류를 처리하는 코드보다 먼저 살펴봐야 했던 것은 “이 계정에서 무엇이 보이는가”였다.</p>
<blockquote>
<p>이 글은 당시 개발 기록을 재구성한 회고다. 고객사, 작업 날짜, 실제 계정명과 배포 식별자는 제외했다. 아래 계정과 테이블 이름은 설명용 가명이며 SQL은 진단 예시다. 글 작성 과정에서 운영 환경이나 Oracle 재현 실험을 다시 실행한 것은 아니다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="권한-오류가-아니라-존재-판정부터">권한 오류가 아니라 존재 판정부터<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EA%B6%8C%ED%95%9C-%EC%98%A4%EB%A5%98%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%A1%B4%EC%9E%AC-%ED%8C%90%EC%A0%95%EB%B6%80%ED%84%B0" class="hash-link" aria-label="권한 오류가 아니라 존재 판정부터에 대한 직접 링크" title="권한 오류가 아니라 존재 판정부터에 대한 직접 링크" translate="no">​</a></h2>
<p>기동 코드의 의도는 단순했다. 필요한 테이블을 확인하고, 없으면 생성한다. 개발 환경에서는 애플리케이션이 테이블을 소유하고 DDL도 실행할 수 있어서 자연스러운 흐름이었다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">테이블 존재 여부 조회</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  -&gt; 있으면 계속 기동</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  -&gt; 없으면 CREATE TABLE</span><br></span></code></pre></div></div>
<p>다른 환경에서는 DBA가 스키마를 미리 만들고 애플리케이션 계정에는 필요한 데이터 접근 권한만 부여할 수 있다. 이때 존재 판정을 잘못하면, 이미 있는 테이블을 만들려는 코드가 실행된다. 마지막에 보이는 오류는 권한 부족이지만 앞에서 잘못 내린 판단은 테이블 부재다.</p>
<p>권한을 더 주는 방식으로 접근하면 불필요한 생성 시도 자체는 남는다. 먼저 테이블이 없다고 판단한 조회를 확인해야 했다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="대소문자가-만든-첫-번째-오판">대소문자가 만든 첫 번째 오판<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EB%8C%80%EC%86%8C%EB%AC%B8%EC%9E%90%EA%B0%80-%EB%A7%8C%EB%93%A0-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%EC%98%A4%ED%8C%90" class="hash-link" aria-label="대소문자가 만든 첫 번째 오판에 대한 직접 링크" title="대소문자가 만든 첫 번째 오판에 대한 직접 링크" translate="no">​</a></h2>
<p>Oracle은 따옴표로 감싸지 않은 식별자를 대문자로 해석한다. 예를 들어 다음 DDL의 테이블 이름은 카탈로그에 <code>APP_SETTINGS</code>로 저장된다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">CREATE</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">TABLE</span><span class="token plain"> app_settings </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  id NUMBER </span><span class="token keyword" style="color:#00009f">PRIMARY</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">KEY</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>반면 아래 조회에서 <code>'app_settings'</code>는 식별자가 아니라 문자열 리터럴이다. 대문자로 저장된 이름을 이 조건으로 찾으면 일치하지 않는다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> table_name</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> user_tables</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> table_name </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'app_settings'</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>당시 기록에는 이 차이 때문에 존재 확인이 실패하고 생성 경로로 넘어간 과정이 남아 있다. DDL의 이름 규칙과 애플리케이션의 식별자 처리를 맞추는 수정이 필요했다.</p>
<p>그렇다고 대소문자만 맞추면 끝나는 문제는 아니었다. 이름을 비교하기 전에, 조회 대상에 그 테이블이 들어오는지부터 따져야 했다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="user_tables는-내가-접근할-수-있는-테이블-목록이-아니다"><code>USER_TABLES</code>는 내가 접근할 수 있는 테이블 목록이 아니다<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#user_tables%EB%8A%94-%EB%82%B4%EA%B0%80-%EC%A0%91%EA%B7%BC%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-%ED%85%8C%EC%9D%B4%EB%B8%94-%EB%AA%A9%EB%A1%9D%EC%9D%B4-%EC%95%84%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="user_tables는-내가-접근할-수-있는-테이블-목록이-아니다에 대한 직접 링크" title="user_tables는-내가-접근할-수-있는-테이블-목록이-아니다에 대한 직접 링크" translate="no">​</a></h2>
<p>설명용 계정으로 구조를 줄이면 다음과 같다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">DATA_OWNER   테이블 소유 계정</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">APP_CLIENT   애플리케이션 접속 계정</span><br></span></code></pre></div></div>
<p><code>APP_CLIENT</code>가 <code>DATA_OWNER</code>의 테이블을 조회할 권한이 있다고 해서 그 테이블의 소유자가 되는 것은 아니다. Oracle의 <code>USER_TABLES</code>는 현재 사용자가 소유한 테이블을 보여준다. 접근 가능한 테이블을 보여주는 <code>ALL_TABLES</code>와 범위가 다르다.</p>
<p>앱이 <code>APP_CLIENT</code>로 접속한 뒤 <code>USER_TABLES</code>에서 대상 테이블을 찾으면 결과가 없을 수 있다. 테이블이 없어서가 아니라 다른 계정이 소유하고 있기 때문이다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">-- 애플리케이션이 사용하는 계정으로 실행하는 진단 예시</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">USER</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> dual</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">SELECT</span><span class="token plain"> owner</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> table_name</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">FROM</span><span class="token plain"> all_tables</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">WHERE</span><span class="token plain"> LOWER</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">table_name</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'app_settings'</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>두 조회를 함께 보면 접속 사용자와 접근 가능한 테이블의 소유자를 비교할 수 있다. 같은 이름의 테이블이 여러 스키마에 있을 수 있으므로, 결과를 확인한 뒤에는 의도한 소유자까지 조건에 넣어야 한다.</p>
<p>여기서도 결과가 없다는 이유만으로 테이블이 없다고 단정하면 안 된다. <code>ALL_TABLES</code> 역시 현재 사용자에게 접근 가능한 테이블만 보여준다. 객체 부재와 접근 권한 부족을 구분하려면 DBA의 확인이 필요할 수 있다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="current_schema를-바꿔도-소유자가-되지는-않는다"><code>CURRENT_SCHEMA</code>를 바꿔도 소유자가 되지는 않는다<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#current_schema%EB%A5%BC-%EB%B0%94%EA%BF%94%EB%8F%84-%EC%86%8C%EC%9C%A0%EC%9E%90%EA%B0%80-%EB%90%98%EC%A7%80%EB%8A%94-%EC%95%8A%EB%8A%94%EB%8B%A4" class="hash-link" aria-label="current_schema를-바꿔도-소유자가-되지는-않는다에 대한 직접 링크" title="current_schema를-바꿔도-소유자가-되지는-않는다에 대한 직접 링크" translate="no">​</a></h2>
<p>소유자와 접속 사용자가 다르면 <code>CURRENT_SCHEMA</code> 변경이나 시노님을 떠올리기 쉽다. 둘 다 객체 이름을 해석하는 문제를 풀 때 유용하다.</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">-- 실제 계정명이 아닌 설명용 예시</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">ALTER</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SESSION</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">SET</span><span class="token plain"> CURRENT_SCHEMA </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> DATA_OWNER</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>하지만 이 설정은 접속 사용자를 바꾸지 않는다. 추가 권한을 부여하지도 않는다. 소유자를 생략한 객체 이름을 어느 스키마에서 찾을지 바꾸는 것과, <code>USER_TABLES</code>가 보여주는 소유 관계는 별개다.</p>
<p>시노님도 마찬가지다. 다른 스키마의 테이블을 편하게 참조할 이름을 만들 수 있지만, 그 테이블이 접속 계정의 소유가 되는 것은 아니다.</p>
<p>당시 존재 판정은 여전히 <code>USER_TABLES</code>를 보고 있었다. 데이터 조회의 이름 해석을 고쳐도 기동 점검은 계속 테이블이 없다는 결론에 도달했다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="예외를-삼키는-수정도-같은-전제에-묶였다">예외를 삼키는 수정도 같은 전제에 묶였다<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EC%98%88%EC%99%B8%EB%A5%BC-%EC%82%BC%ED%82%A4%EB%8A%94-%EC%88%98%EC%A0%95%EB%8F%84-%EA%B0%99%EC%9D%80-%EC%A0%84%EC%A0%9C%EC%97%90-%EB%AC%B6%EC%98%80%EB%8B%A4" class="hash-link" aria-label="예외를 삼키는 수정도 같은 전제에 묶였다에 대한 직접 링크" title="예외를 삼키는 수정도 같은 전제에 묶였다에 대한 직접 링크" translate="no">​</a></h2>
<p>다음 시도는 생성에 실패하더라도 테이블이 이미 있으면 계속 기동하는 방식이었다. 하지만 “이미 있다”는 재확인에 같은 카탈로그 조회를 쓰면 결과도 같다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">CREATE TABLE 실패</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  -&gt; 기존 테이블인지 다시 확인</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  -&gt; USER_TABLES에서는 여전히 찾지 못함</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  -&gt; 오류 전파</span><br></span></code></pre></div></div>
<p>기동 DDL을 실행하는 함수도 하나가 아니었다. 한 경로의 예외 처리를 고쳤다고 다른 스키마 초기화 경로까지 바뀌는 것은 아니었다. 같은 오류 번호만 따라가다 보니, 수정한 함수가 실제 실패 지점인지와 그 함수의 조회 전제가 맞는지를 따로 확인해야 했다.</p>
<p>이 시점부터 질문을 바꿨다. “어떤 오류를 무시할까”가 아니라 “이 배포에서 스키마를 관리하는 주체는 누구인가”를 먼저 정리했다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="스키마-관리와-애플리케이션-기동의-분리">스키마 관리와 애플리케이션 기동의 분리<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EC%8A%A4%ED%82%A4%EB%A7%88-%EA%B4%80%EB%A6%AC%EC%99%80-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EA%B8%B0%EB%8F%99%EC%9D%98-%EB%B6%84%EB%A6%AC" class="hash-link" aria-label="스키마 관리와 애플리케이션 기동의 분리에 대한 직접 링크" title="스키마 관리와 애플리케이션 기동의 분리에 대한 직접 링크" translate="no">​</a></h2>
<p>애플리케이션이 스키마를 관리하는 환경이라면 마이그레이션이나 초기화 DDL이 필요할 수 있다. 반대로 DBA가 미리 준비한 외부 스키마에 접속하는 환경에서는 같은 기동 경로를 그대로 적용하기 어렵다.</p>
<p>당시 수정은 소유자가 분리된 외부 스키마를 명시하고, 연결에서 사용할 스키마를 설정하며, 그 모드에서 기동 시 추가 DDL을 실행하지 않는 방향이었다. 소유자를 인식하도록 카탈로그 조회를 모두 바꾸는 방법도 검토했지만, 이 환경에서는 앱이 스키마를 수정하는 주체가 아니었다.</p>
<p>이것이 모든 스키마 검증을 없애도 된다는 뜻은 아니다. 필요한 테이블과 컬럼이 준비됐는지 확인하는 절차는 별도로 필요하다. DDL을 실행하지 않는 것과 올바른 스키마임을 확인하는 것은 다른 일이다. 명시적으로 스키마 동기화를 켜는 설정도 외부 스키마 운영 방식과 충돌하지 않는지 점검해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="로컬에-재현해야-했던-것은-데이터보다-권한-구조">로컬에 재현해야 했던 것은 데이터보다 권한 구조<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EB%A1%9C%EC%BB%AC%EC%97%90-%EC%9E%AC%ED%98%84%ED%95%B4%EC%95%BC-%ED%96%88%EB%8D%98-%EA%B2%83%EC%9D%80-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B3%B4%EB%8B%A4-%EA%B6%8C%ED%95%9C-%EA%B5%AC%EC%A1%B0" class="hash-link" aria-label="로컬에 재현해야 했던 것은 데이터보다 권한 구조에 대한 직접 링크" title="로컬에 재현해야 했던 것은 데이터보다 권한 구조에 대한 직접 링크" translate="no">​</a></h2>
<p>코드만 보면 수정이 맞아 보이는 순간이 여러 번 있었다. 확인이 부족했던 것은 실제 연결에서 그 코드가 어떻게 동작하는지였다.</p>
<p>당시에는 로컬 Oracle 컨테이너에 소유 계정과 접속 계정을 분리해 만들고, 애플리케이션 계정에 필요한 데이터 접근 권한을 부여한 환경을 구성했다. 같은 자리에서 수정 전후의 기동 경로와 엔티티 조회를 비교했다.</p>
<p>테이블을 만들고 조회하는 테스트만으로는 부족했다. 다음 조건이 함께 있어야 원래의 실패를 재현할 수 있었다.</p>
<ul>
<li class="">테이블 소유자와 애플리케이션 접속 사용자가 다르다.</li>
<li class="">애플리케이션 계정은 테이블을 생성할 권한이 없다.</li>
<li class="">테이블은 미리 존재하고 앱에는 필요한 데이터 접근 권한이 있다.</li>
<li class="">실제 연결과 기동 코드를 거쳐 존재 확인과 엔티티 조회를 실행한다.</li>
</ul>
<p>이 재현은 연결 풀의 세션 설정이 ORM을 거쳐 실제 연결에 적용되는지 확인하는 데도 쓰였다. 가짜 카탈로그 응답으로 분기만 통과시키는 테스트와는 확인하는 대상이 달랐다.</p>
<p>이 글에 새 실측값을 싣지 않은 이유도 같다. 당시 재현 기록과 지금 다시 실행한 결과를 섞으면, 독자는 어느 버전과 환경에서 검증했는지 알 수 없게 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="코드-수정은-설정-전달까지-이어져야-했다">코드 수정은 설정 전달까지 이어져야 했다<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EC%BD%94%EB%93%9C-%EC%88%98%EC%A0%95%EC%9D%80-%EC%84%A4%EC%A0%95-%EC%A0%84%EB%8B%AC%EA%B9%8C%EC%A7%80-%EC%9D%B4%EC%96%B4%EC%A0%B8%EC%95%BC-%ED%96%88%EB%8B%A4" class="hash-link" aria-label="코드 수정은 설정 전달까지 이어져야 했다에 대한 직접 링크" title="코드 수정은 설정 전달까지 이어져야 했다에 대한 직접 링크" translate="no">​</a></h2>
<p>식별자 처리처럼 기존 배포에 영향을 주는 변경은 기본 동작을 유지하고 설정으로 켜도록 만들 수 있다. 그런데 새 기능이 이미지에 들어 있어도 설정이 꺼져 있으면 동작은 달라지지 않는다.</p>
<p>당시에는 설정 템플릿의 주석과 배포 전 검사에 빈틈이 있었다. 새 이미지를 넣는 것만으로 수정이 적용됐다고 생각하기 쉬운 상태였다.</p>
<p>그래서 환경변수를 추가할 때 코드뿐 아니라 템플릿과 사전 검사도 함께 바꾸는 것을 재발 방지 항목으로 남겼다. 특히 외부 스키마를 쓰면서 자동 동기화를 켜는 것처럼 충돌하는 설정은 기동 전에 드러나야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="같은-오류가-다시-나오면">같은 오류가 다시 나오면<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#%EA%B0%99%EC%9D%80-%EC%98%A4%EB%A5%98%EA%B0%80-%EB%8B%A4%EC%8B%9C-%EB%82%98%EC%98%A4%EB%A9%B4" class="hash-link" aria-label="같은 오류가 다시 나오면에 대한 직접 링크" title="같은 오류가 다시 나오면에 대한 직접 링크" translate="no">​</a></h2>
<p><code>ORA-01031</code>이 다시 보인다면 곧바로 권한을 추가하거나 예외를 무시하는 코드를 넣기보다, 다음 순서로 조사하려 한다.</p>
<ol>
<li class="">애플리케이션이 실제로 접속한 사용자를 확인한다.</li>
<li class="">대상 테이블의 소유자와 저장된 식별자 표기를 확인한다.</li>
<li class="">존재 판정이 <code>USER_TABLES</code>와 <code>ALL_TABLES</code> 중 무엇을 조회하는지 확인한다.</li>
<li class="">실패한 DDL을 실행한 기동 경로와 스키마 관리 주체를 확인한다.</li>
<li class="">수정 코드뿐 아니라 필요한 설정이 전달됐는지 확인한다.</li>
</ol>
<p>테이블이 있는데 없다고 판단한 문제를 CREATE 권한으로 풀려고 하면, 잘못된 존재 판정은 그대로 남는다. 이번 경험에서 바뀐 것은 오류를 처리하는 순서였다. 마지막 오류보다 먼저, 실패한 DDL을 실행하게 만든 판단을 확인한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/oracle-startup-schema-retrospective#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/26/refrn/USER_TABLES.html" target="_blank" rel="noopener noreferrer" class="">Oracle Database Reference: USER_TABLES</a>: 현재 사용자가 소유한 테이블</li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/26/refrn/ALL_TABLES.html" target="_blank" rel="noopener noreferrer" class="">Oracle Database Reference: ALL_TABLES</a>: 현재 사용자가 접근 가능한 테이블</li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/ALTER-SESSION.html" target="_blank" rel="noopener noreferrer" class="">Oracle SQL Language Reference: ALTER SESSION</a>: <code>CURRENT_SCHEMA</code>의 이름 해석과 권한 범위</li>
<li class=""><a href="https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Database-Object-Names-and-Qualifiers.html" target="_blank" rel="noopener noreferrer" class="">Oracle SQL Language Reference: Database Object Names and Qualifiers</a>: 인용 식별자와 비인용 식별자의 대소문자 규칙</li>
</ul>]]></content:encoded>
            <category>deep-connector</category>
            <category>oracle</category>
            <category>troubleshooting</category>
            <category>on-premise</category>
            <category>database</category>
        </item>
        <item>
            <title><![CDATA[RAG 평가의 0점과 측정 불가를 구분하기]]></title>
            <link>https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured</link>
            <guid>https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured</guid>
            <pubDate>Tue, 08 Sep 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[검색 실패와 평가하지 못한 항목을 같은 값으로 집계하면 평균이 달라진다. Deep Connector의 평가 집계 코드를 설명용 데이터로 실행하며 null, 0, 측정 행 수의 의미를 확인했다.]]></description>
            <content:encoded><![CDATA[<p>평가 화면에 근거 검색 점수가 100%로 나왔다고 하자. 그런데 근거를 요구한 질문 중 실제로 측정한 것은 일부뿐이었다면, 그 숫자를 전체 검색 품질로 읽어도 될까?</p>
<p>Deep Connector의 평가 집계에서는 평균과 함께 근거를 요구한 행 수, 측정 가능한 근거가 있는 행 수를 구분한다. 이 설계를 검토하면서 작은 데이터를 만들어 집계 함수를 실행해 봤다. 같은 입력도 <code>null</code>과 <code>0</code>을 어떻게 처리하느냐에 따라 평균이 30%, 50%, 75%로 달라졌다.</p>
<blockquote>
<p>아래 질문과 점수는 집계 규칙을 확인하려고 만든 설명용 데이터다. 고객 문서나 실제 평가 성적을 사용하지 않았다. 실험은 저장소의 특정 구현 스냅샷에서 가져온 순수 함수만 실행했으며, 검색 엔진이나 LLM을 호출한 성능 실험은 아니다.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="점수가-없는-이유부터-분리">점수가 없는 이유부터 분리<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#%EC%A0%90%EC%88%98%EA%B0%80-%EC%97%86%EB%8A%94-%EC%9D%B4%EC%9C%A0%EB%B6%80%ED%84%B0-%EB%B6%84%EB%A6%AC" class="hash-link" aria-label="점수가 없는 이유부터 분리에 대한 직접 링크" title="점수가 없는 이유부터 분리에 대한 직접 링크" translate="no">​</a></h2>
<p>근거 평가에는 질문과 기대 답변만으로 부족할 때가 있다. 어떤 문서의 어느 내용이 답변을 뒷받침하는지도 정답 데이터에 있어야 한다. 검색 결과에 그 근거가 포함됐는지 비교하려면, 기대 근거 자체를 읽고 해석할 수 있어야 한다.</p>
<p>이때 <code>0</code>은 비교를 수행했지만 찾은 근거나 덮은 내용이 없다는 측정값이다. <code>null</code>은 해당 지표의 측정값이 없다는 뜻이다. 근거를 지정했어도 참조를 해석하지 못하면 측정값을 만들지 못할 수 있다.</p>
<p>근거를 아예 지정하지 않은 질문도 별도로 봐야 한다. 문서 이름만으로 검색 순위를 평가하는 질문을 근거 본문 평가에 실패한 질문으로 세면, 서로 다른 평가 대상을 섞게 된다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">근거를 요구하지 않음  -&gt; 이 근거 지표의 평가 대상 아님</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">근거를 요구했으나 측정 불가  -&gt; 측정값 없음</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">근거를 측정했으나 못 찾음  -&gt; 측정값 0</span><br></span></code></pre></div></div>
<p>모두 화면에서 빈칸처럼 보일 수 있지만, 고쳐야 하는 대상은 다르다. 검색 실패를 조사할 때와 정답 근거의 참조 문제를 조사할 때 같은 작업을 시작해서는 안 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="같은-점수-배열로-평균을-바꿔-보기">같은 점수 배열로 평균을 바꿔 보기<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#%EA%B0%99%EC%9D%80-%EC%A0%90%EC%88%98-%EB%B0%B0%EC%97%B4%EB%A1%9C-%ED%8F%89%EA%B7%A0%EC%9D%84-%EB%B0%94%EA%BF%94-%EB%B3%B4%EA%B8%B0" class="hash-link" aria-label="같은 점수 배열로 평균을 바꿔 보기에 대한 직접 링크" title="같은 점수 배열로 평균을 바꿔 보기에 대한 직접 링크" translate="no">​</a></h2>
<p>설명용 입력에 측정값 <code>1</code>, <code>0.5</code>, <code>0</code>을 넣었다. 이어서 근거를 요구했으나 측정하지 못한 행과 근거를 요구하지 않은 행을 하나씩 추가했다. 이 지표만 배열로 꺼내면 다음과 같다.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> values </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword null nil" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword null nil" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>평균을 낼 때 <code>null</code>을 전부 <code>0</code>으로 바꾸면 합계는 그대로지만 분모가 늘어난다. 반대로 <code>filter(Boolean)</code>으로 빈 값을 제거하면 측정값 <code>0</code>까지 사라진다. JavaScript에서 <code>0</code>과 <code>null</code>은 둘 다 falsy이기 때문이다.</p>
<p>아래 코드는 이 차이를 보여주는 독립 실행 예시다. 제품 소스 전체를 옮긴 코드가 아니며, 입력은 유한한 0~1 값과 <code>null</code>로 제한했다.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> values </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword null nil" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword null nil" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:#d73a49">mean</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token parameter">xs</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> xs</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">length</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">?</span><span class="token plain"> xs</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">reduce</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">sum</span><span class="token parameter punctuation" style="color:#393A34">,</span><span class="token parameter"> value</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> sum </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">/</span><span class="token plain"> xs</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">length</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token keyword null nil" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token console class-name">console</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">log</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">mean</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">values</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">map</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">value</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> value </span><span class="token operator" style="color:#393A34">??</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token console class-name">console</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">log</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">mean</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">values</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">filter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">value</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">typeof</span><span class="token plain"> value </span><span class="token operator" style="color:#393A34">===</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"number"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token console class-name">console</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">log</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">mean</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">values</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">filter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token known-class-name class-name">Boolean</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></span></code></pre></div></div>
<p>실행 결과는 다음 순서였다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">0.3</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">0.5</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">0.75</span><br></span></code></pre></div></div>
<p>첫 번째 값은 측정하지 못했거나 대상이 아닌 행까지 실패로 센 결과다. 마지막 값은 측정된 실패를 분모에서 지운 결과다. 가운데 값만 이 예시의 측정값 <code>1</code>, <code>0.5</code>, <code>0</code>을 모두 보존했다.</p>
<p>검색 결과는 하나도 바꾸지 않았다. 바꾼 것은 평균을 낼 때 포함한 행뿐이다. 다만 <code>typeof value === "number"</code>는 일반적인 입력 검증기가 아니다. <code>NaN</code>이나 무한대도 number 타입이므로, 외부 입력을 받는 코드라면 유한성과 허용 범위를 별도로 확인해야 한다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="평균-옆에-측정-행-수를-붙인-이유">평균 옆에 측정 행 수를 붙인 이유<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#%ED%8F%89%EA%B7%A0-%EC%98%86%EC%97%90-%EC%B8%A1%EC%A0%95-%ED%96%89-%EC%88%98%EB%A5%BC-%EB%B6%99%EC%9D%B8-%EC%9D%B4%EC%9C%A0" class="hash-link" aria-label="평균 옆에 측정 행 수를 붙인 이유에 대한 직접 링크" title="평균 옆에 측정 행 수를 붙인 이유에 대한 직접 링크" translate="no">​</a></h2>
<p>숫자만 골라 평균을 내더라도 문제가 남는다. 근거를 요구한 질문 중 얼마나 측정했는지 모르면, 높은 평균이 얼마나 좁은 범위를 설명하는지 알 수 없다.</p>
<p>검토한 집계 코드에는 다음 카운터가 있다.</p>
<ul>
<li class=""><code>evidence_rows</code>는 근거 목록에 항목이 하나 이상 있거나 공백이 아닌 기대 근거 본문이 있는 행 수다.</li>
<li class=""><code>evidence_measured</code>는 그중 측정 가능한 근거가 있는 행 수다.</li>
</ul>
<p>근거 목록은 항목 중 하나라도 <code>measurable</code>이면 측정 가능한 행으로 센다. 따라서 이 카운터는 질문 안의 모든 근거가 준비됐다는 보증이 아니다. 단수 근거 본문을 쓰는 경로에서는 본문이 있고 coverage 값이 <code>null</code>이나 <code>undefined</code>가 아니면 측정 가능한 행으로 분류한다. 여기서 coverage <code>0</code>은 제외하면 안 된다.</p>
<p>설명용 입력을 실제 집계 함수에 넣었을 때, 전체 행은 5개이고 근거를 요구한 행은 4개였다. 그중 측정 가능한 행은 3개였고 평균 coverage는 0.5였다. 표시 함수에도 같은 요약을 넘겼다. 예시에서는 recall과 coverage에 같은 값을 넣어 표시 결과를 비교했다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">근거 50% 찾음 · 50% 덮음 (4행 중 3행만 측정)</span><br></span></code></pre></div></div>
<p>이 문구는 “전체 질문에서 근거를 절반 찾았다”와 다르다. 측정된 값의 평균이 50%이고, 근거를 요구한 행 중 측정하지 못한 행이 남아 있다는 뜻이다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="100여도-측정은-끝나지-않을-수-있다">100%여도 측정은 끝나지 않을 수 있다<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#100%EC%97%AC%EB%8F%84-%EC%B8%A1%EC%A0%95%EC%9D%80-%EB%81%9D%EB%82%98%EC%A7%80-%EC%95%8A%EC%9D%84-%EC%88%98-%EC%9E%88%EB%8B%A4" class="hash-link" aria-label="100%여도 측정은 끝나지 않을 수 있다에 대한 직접 링크" title="100%여도 측정은 끝나지 않을 수 있다에 대한 직접 링크" translate="no">​</a></h2>
<p>별도 입력에서는 근거를 요구하는 행을 세 개 만들었다. 그중 한 행에만 측정값 <code>1</code>을 주고 나머지는 <code>null</code>로 뒀다. 실제 집계 함수와 표시 함수의 결과는 이랬다.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#393A34"><span class="token plain">근거 100% 찾음 · 100% 덮음 (3행 중 1행만 측정)</span><br></span></code></pre></div></div>
<p>평균 100% 자체가 산술적으로 틀린 것은 아니다. 다만 측정되지 않은 행의 검색 성능까지 설명하지는 못한다. 측정하기 쉬운 항목만 남았다면 평균이 전체 데이터셋을 대표하지 못할 수도 있다.</p>
<p>한 행도 측정하지 못한 경우도 따로 실행했다. 근거를 요구한 행이 하나라면 평균은 <code>null</code>이고 표시 문자열은 <code>근거 1행 중 0행만 측정</code>이었다. 근거를 아예 요구하지 않은 행만 있으면 평균과 근거 요약 문자열이 모두 <code>null</code>이었다. 측정값 <code>0</code>만 있는 경우에는 <code>근거 0% 찾음 · 0% 덮음</code>이 유지됐다.</p>
<p>평균을 표시하지 않는다고 측정 불가 상태까지 숨겨서는 안 된다. 값이 없는 이유를 보여줘야 정답 데이터 준비 문제를 검색 실패로 오해하지 않는다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="recall과-coverage도-같은-평균이-아니다">recall과 coverage도 같은 평균이 아니다<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#recall%EA%B3%BC-coverage%EB%8F%84-%EA%B0%99%EC%9D%80-%ED%8F%89%EA%B7%A0%EC%9D%B4-%EC%95%84%EB%8B%88%EB%8B%A4" class="hash-link" aria-label="recall과 coverage도 같은 평균이 아니다에 대한 직접 링크" title="recall과 coverage도 같은 평균이 아니다에 대한 직접 링크" translate="no">​</a></h2>
<p>근거 recall은 측정 가능한 기대 근거 중 얼마나 찾았는지를 본다. coverage는 검색 결과가 기대 근거 내용을 얼마나 덮는지를 본다. 이 글의 실험에서 두 지표에 같은 숫자를 넣은 것은 집계와 표시를 비교하기 위한 설정이지, 두 지표가 같다는 뜻이 아니다.</p>
<p>검토한 집계 구현도 recall과 coverage에 숫자 값이 있는 행을 각각 세어 평균을 낸다. 두 지표의 평균 분모가 항상 같다고 가정할 수 없고, <code>evidence_measured</code>를 어느 평균의 분모로든 그대로 대입해서도 안 된다.</p>
<p>목록에 측정 가능한 근거가 있는지 세는 조건과 개별 지표 값이 숫자인지 확인하는 조건은 다른 검사다. 카운터와 지표가 일관된지는 값을 만드는 앞단에서도 확인해야 한다.</p>
<p>이 구분을 모든 평가 지표에 일괄 적용해서도 안 된다. 예를 들어 검색 순위의 MRR은 못 찾은 항목을 0으로 계산하는 규칙을 쓸 수 있다. 근거 지표에서 측정값이 없는 행을 제외한다는 이유로, 검색 실패 행을 모든 지표의 분모에서 빼면 안 된다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="이번에-확인한-범위">이번에 확인한 범위<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#%EC%9D%B4%EB%B2%88%EC%97%90-%ED%99%95%EC%9D%B8%ED%95%9C-%EB%B2%94%EC%9C%84" class="hash-link" aria-label="이번에 확인한 범위에 대한 직접 링크" title="이번에 확인한 범위에 대한 직접 링크" translate="no">​</a></h2>
<p>저장소의 집계 함수와 표시 함수를 가져와 TypeScript 5.6.3으로 ES2022 대상으로 변환한 뒤 Node.js v22.22.3에서 실행했다. 혼합 입력, 일부만 측정한 입력, 전부 측정 불가인 입력, 근거를 요구하지 않은 입력, 측정값이 0인 입력을 각각 확인했다. 앞의 독립 실행 예시도 별도로 실행했다.</p>
<p>확인한 것은 준비된 입력이 어떤 평균, 카운터, 문자열로 바뀌는지다. 실제 정답 데이터의 정확성, 근거 참조의 해석, 검색 품질, LLM 답변 품질, 브라우저 화면 동작은 이 실험으로 검증하지 않았다. 설명용 입력을 직접 넣었으므로 전체 API 경로의 검증 결과로 볼 수도 없다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="평가-결과를-읽는-순서">평가 결과를 읽는 순서<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#%ED%8F%89%EA%B0%80-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EC%9D%BD%EB%8A%94-%EC%88%9C%EC%84%9C" class="hash-link" aria-label="평가 결과를 읽는 순서에 대한 직접 링크" title="평가 결과를 읽는 순서에 대한 직접 링크" translate="no">​</a></h2>
<p>평가 평균을 비교하기 전에 근거를 요구한 행과 측정 가능한 행부터 확인하려 한다. 측정 범위가 달라졌다면 같은 질문을 넣었어도 평균이 설명하는 대상은 달라질 수 있다. 측정 불가 항목을 조사하고, 비교 대상과 조건을 맞춘 뒤 검색 설정의 효과를 판단하는 편이 낫다.</p>
<p>낮은 점수를 고치는 일과 점수를 낼 수 없는 상태를 고치는 일은 다르다. 평가 코드가 그 차이를 보존해야 개발자도 어디부터 고칠지 결정할 수 있다.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://tech.brain-crew.com/product/rag-evaluation-zero-vs-unmeasured#references" class="hash-link" aria-label="References에 대한 직접 링크" title="References에 대한 직접 링크" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://developer.mozilla.org/en-US/docs/Glossary/Falsy" target="_blank" rel="noopener noreferrer" class="">MDN: Falsy</a>: <code>0</code>과 <code>null</code>의 Boolean 변환</li>
<li class=""><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter" target="_blank" rel="noopener noreferrer" class="">MDN: Array.prototype.filter()</a>: 필터 조건에 따른 요소 선택</li>
<li class=""><a href="https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-unranked-retrieval-sets-1.html" target="_blank" rel="noopener noreferrer" class="">Introduction to Information Retrieval: Evaluation of unranked retrieval sets</a>: 일반적인 precision과 recall의 정의</li>
</ul>]]></content:encoded>
            <category>deep-connector</category>
            <category>rag</category>
            <category>evaluation</category>
            <category>data-quality</category>
            <category>javascript</category>
        </item>
    </channel>
</rss>