<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Identifier on jyukki's Blog</title><link>https://jyukki.com/tags/identifier/</link><description>Recent content in Identifier on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Fri, 21 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/identifier/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: 식별자 정규화와 비교 정책, Unicode 입력이 권한 우회가 되지 않게 설계하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-identifier-normalization-security-playbook/</link><pubDate>Fri, 21 Aug 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-identifier-normalization-security-playbook/</guid><description>이메일·사용자명·도메인·외부 리소스 ID를 표시 문자열과 보안 식별자로 분리하고, 허용 문자·정규화·중복·감사 기준을 설계하는 실무 플레이북입니다.</description><content:encoded><![CDATA[<p>사용자명, 이메일, 조직 slug, 외부 API의 account ID, 허용할 hostname은 모두 문자열로 보입니다. 그래서 많은 서비스가 입력에서 공백을 제거하고 <code>lower()</code>를 적용한 뒤 같은 값인지 비교합니다. 하지만 이 단계는 화면을 보기 좋게 만드는 전처리가 아니라 <strong>인증·권한·중복 방지의 의미를 결정하는 정책</strong>입니다. 보기에 비슷한 Unicode 문자, 정규화 방식 차이, 대소문자 규칙이 다른 외부 시스템, 오래된 데이터의 표현 차이를 한 번에 처리하려 하면 계정 병합, 허용 목록 우회, 감사 기록 불일치가 생길 수 있습니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-spring-validation/">Spring Validation</a>, <a href="/learning/deep-dive/deep-dive-oauth2-oidc/">OAuth2·OIDC</a>, <a href="/learning/deep-dive/deep-dive-object-level-authorization-bola-playbook/">BOLA 방지와 객체 수준 인가</a>, <a href="/learning/deep-dive/deep-dive-structured-logging/">구조화 로깅</a>을 잇는 보안 경계 글입니다. 핵심은 모든 문자열을 똑같이 정규화하는 것이 아니라, <strong>어떤 값이 사람에게 보이는 라벨이고 어떤 값이 보안 결정을 내리는 식별자인지 먼저 분리하는 것</strong>입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>이메일, 사용자명, hostname, 외부 subject처럼 성격이 다른 문자열에 같은 <code>trim + lower</code> 규칙을 쓰면 안 되는 이유를 이해합니다.</li>
<li>원본값, 표시값, 비교용 canonical 값, 내부 immutable ID를 어떤 책임으로 나눌지 설계할 수 있습니다.</li>
<li>입력 허용 문자, Unicode 정규화, unique constraint, 로그 마스킹, 마이그레이션을 숫자와 중단 조건으로 운영할 수 있습니다.</li>
<li>로그인·권한·allowlist에서 편의성보다 먼저 확인해야 할 비교 경계를 정할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-표시-문자열과-보안-식별자는-같은-컬럼이-아니다">1) 표시 문자열과 보안 식별자는 같은 컬럼이 아니다</h3>
<p><code>김철수</code>, <code>München</code>, 상품 제목처럼 사람이 읽는 값은 다양한 Unicode 표현과 언어를 허용하는 것이 제품 경험에 맞을 수 있습니다. 반면 로그인 ID, 직원 코드, tenant slug, 내부 리소스 key, redirect hostname은 시스템이 <strong>같다/다르다</strong>를 결정하는 값입니다. 이 두 종류를 하나의 <code>name</code> 컬럼에 넣고 같은 비교 함수를 적용하면, UX 요구와 보안 요구가 충돌할 때 어느 쪽도 명확히 지킬 수 없습니다.</p>
<p>권장 모델은 네 층입니다.</p>
<table>
  <thead>
      <tr>
          <th>층</th>
          <th>예시</th>
          <th>책임</th>
          <th>변경 가능 여부</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>내부 ID</td>
          <td><code>user_id</code>, UUIDv7</td>
          <td>권한 조인·외래키·감사 기준</td>
          <td>불변</td>
      </tr>
      <tr>
          <td>원본 입력</td>
          <td>사용자가 제출한 사용자명</td>
          <td>분쟁 재현·입력 오류 분석</td>
          <td>접근 통제 하 보존</td>
      </tr>
      <tr>
          <td>표시값</td>
          <td>프로필에 보이는 이름</td>
          <td>사용자 경험·국제화</td>
          <td>정책상 변경 가능</td>
      </tr>
      <tr>
          <td>canonical 식별자</td>
          <td><code>login_key</code>, tenant slug</td>
          <td>로그인·중복 판단·허용 목록</td>
          <td>변경 절차 필요</td>
      </tr>
  </tbody>
</table>
<p>권한 체크는 표시값이나 이메일 문자열로 하지 않고 내부 ID와 membership을 사용합니다. 예를 들어 <code>/projects/{slug}</code>는 slug로 프로젝트를 찾은 뒤, 반드시 <code>project_id</code>와 요청자의 principal ID로 인가합니다. URL의 문자열이 정규화됐다는 사실만으로 객체 접근이 안전해지지 않습니다. 이 원칙이 <a href="/learning/deep-dive/deep-dive-object-level-authorization-bola-playbook/">BOLA 방지</a>의 object-level check와 만나는 지점입니다.</p>
<h3 id="2-unicode-정규화는-필요하지만-동등성-정책을-대신하지-않는다">2) Unicode 정규화는 필요하지만 동등성 정책을 대신하지 않는다</h3>
<p>Unicode에는 사람이 보기에는 같은 글자가 여러 code point 조합으로 표현되는 경우가 있습니다. 예를 들어 <code>é</code>는 하나의 문자일 수도 있고 <code>e</code>와 combining accent의 조합일 수도 있습니다. 비교 전에 NFC 같은 정규화를 적용하면 이런 <strong>정준 동등성(canonical equivalence)</strong> 문제를 줄일 수 있습니다. 그러나 정규화가 &ldquo;비슷해 보이는 모든 문자&quot;를 같은 계정으로 합쳐 주는 것은 아닙니다.</p>
<p>여기서 흔한 과잉 대응이 NFKC 같은 호환성 정규화를 보안 식별자 전체에 일괄 적용하는 일입니다. 일부 호환 문자를 합치는 것이 유용한 입력도 있지만, 의도하지 않은 서로 다른 값을 충돌시킬 수 있고 외부 IdP·이메일 공급자·도메인 규칙과도 어긋날 수 있습니다. <code>lower()</code>를 <code>casefold()</code>로 바꾸는 것도 마찬가지입니다. 더 넓은 case folding은 검색 편의에는 쓸 수 있어도, 이미 발급된 login ID의 의미를 자동으로 바꾸는 근거는 아닙니다.</p>
<p>따라서 질문 순서는 다음이 안전합니다.</p>
<ol>
<li>이 값은 사람이 읽는 라벨인가, 접근 권한을 가르는 key인가?</li>
<li>이 값의 canonical 규칙을 우리 서비스가 소유하는가, 외부 provider가 소유하는가?</li>
<li>한 번 canonicalized한 값이 외부 시스템의 값과 왕복해도 같은 의미를 유지하는가?</li>
<li>충돌이 발견됐을 때 자동 병합 대신 어떤 수동 검토·복구 경로가 있는가?</li>
</ol>
<p>서비스가 소유하는 slug라면 ASCII 소문자, 숫자, <code>-</code>처럼 좁은 allowlist를 정하고 길이를 3~63자로 제한하는 편이 보통 안전합니다. 반대로 OAuth <code>sub</code>는 IdP가 발급한 opaque identifier입니다. 이것을 소문자로 바꾸거나 Unicode 정규화하지 말고, issuer와 함께 원문 그대로 저장·비교해야 합니다. <a href="/learning/deep-dive/deep-dive-oauth2-oidc/">OAuth2·OIDC</a>에서 <code>iss + sub</code> 조합을 신원 키로 보는 이유도 여기 있습니다.</p>
<h3 id="3-입력-검증-canonicalization-인가는-서로-다른-단계다">3) 입력 검증, canonicalization, 인가는 서로 다른 단계다</h3>
<p>이 세 단계를 controller의 validator 하나로 끝내면 책임이 섞입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>request
</span></span><span style="display:flex;"><span>  -&gt; syntax validation: 길이, 허용 문자, 빈 값, encoding
</span></span><span style="display:flex;"><span>  -&gt; canonicalization: 해당 식별자 타입의 명시된 변환만 적용
</span></span><span style="display:flex;"><span>  -&gt; uniqueness / resolution: canonical key로 내부 ID를 찾음
</span></span><span style="display:flex;"><span>  -&gt; authorization: principal ID가 그 내부 ID에 접근할 수 있는지 판단
</span></span><span style="display:flex;"><span>  -&gt; audit: 원본·canonical·결정 결과를 민감도에 맞춰 기록
</span></span></code></pre></div><p>문법 검증은 <a href="/learning/deep-dive/deep-dive-spring-validation/">Spring Validation</a>처럼 빠르고 일관되게 400 계열 오류로 돌려줄 수 있습니다. 하지만 <code>@Pattern</code> 통과는 정책 통과가 아닙니다. 예컨대 hostname은 형식이 맞아도 localhost, link-local, private IP로 해석되거나 allowlist의 registrable domain 밖일 수 있습니다. email은 RFC 형태가 맞아도 로그인용 primary identity인지, 연락용 변경 가능한 주소인지가 별도입니다.</p>
<p>canonicalization은 pure function에 가깝게 유지하고, 입력 타입별로 버전을 둡니다. <code>slug_policy_v2</code>, <code>email_policy_v1</code>처럼 적용한 정책 버전을 저장하면 나중에 규칙을 바꿀 때 어떤 레코드를 재검토해야 하는지 찾기 쉽습니다. 정책 함수가 DB 조회나 네트워크 호출을 섞기 시작하면 재현성과 테스트가 나빠집니다. 외부 DNS 확인, IdP 조회는 canonicalization 이후의 resolution 단계로 분리합니다.</p>
<h3 id="4-데이터베이스-제약이-최종-중복-방지-장치다">4) 데이터베이스 제약이 최종 중복 방지 장치다</h3>
<p>애플리케이션에서 먼저 조회한 뒤 insert하는 방식은 동시 요청 두 개가 같은 canonical ID를 통과시키는 race condition을 만듭니다. canonical column에 unique constraint를 두고, 충돌을 정상적인 비즈니스 결과로 처리해야 합니다. 예시로 tenant slug는 다음처럼 설계할 수 있습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> tenant (
</span></span><span style="display:flex;"><span>  id UUID <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  slug_display <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">63</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  slug_canonical <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">63</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  slug_policy_version <span style="color:#8be9fd;font-style:italic">SMALLINT</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  created_at TIMESTAMPTZ <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">UNIQUE</span> (slug_canonical)
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>unique violation을 무조건 500으로 처리하지 않습니다. 생성 요청이면 <code>409 Conflict</code>와 이미 사용 중이라는 안정적인 error code를 반환하고, retry path에서는 idempotency key와 요청 본문 hash를 함께 확인합니다. 기존 데이터에서 새 규칙의 충돌이 발견되면 자동으로 하나를 rename하지 않습니다. owner, 마지막 로그인, 외부 연동, URL redirect 영향을 확인한 manual-review queue로 보내는 것이 안전합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-식별자-inventory부터-만든다">1) 식별자 inventory부터 만든다</h3>
<p>처음부터 전 테이블을 고치지 말고 로그인, tenant, 권한 allowlist, 외부 연동 ID처럼 영향이 큰 10개를 먼저 분류합니다. 각 항목에는 <code>owner</code>, <code>security role</code>, <code>source of truth</code>, <code>allowed charset</code>, <code>normalization policy</code>, <code>comparison method</code>, <code>storage</code>, <code>migration risk</code>를 적습니다. 특히 아래는 별도 행으로 분리합니다.</p>
<ul>
<li><strong>내부 principal/tenant/resource ID</strong>: 불변 ID로 인가하고 원문 문자열은 lookup 보조로만 사용</li>
<li><strong>로그인 email</strong>: provider 또는 제품 정책을 문서화하고, display email과 verified primary email의 책임을 분리</li>
<li><strong>OIDC subject</strong>: <code>issuer + sub</code> 원문 비교, 임의 case 변환 금지</li>
<li><strong>hostname/IP allowlist</strong>: 문자열 lowercasing보다 URL parse, DNS 재해석, IP range 정책을 우선</li>
<li><strong>사용자 표시 이름</strong>: Unicode 허용 범위와 profanity·spoofing 대응은 하되 계정 key로 재사용 금지</li>
</ul>
<p>우선순위는 <strong>권한 오판 방지 &gt; 기존 계정 충돌 방지 &gt; 감사 재현성 &gt; 검색 편의 &gt; 표시 일관성</strong>입니다. 이 순서가 뒤집히면 보기 좋은 UI를 위해 권한 경계를 느슨하게 만드는 결과가 됩니다.</p>
<h3 id="2-도입은-shadow-관측-후-제한적으로-강제한다">2) 도입은 shadow 관측 후 제한적으로 강제한다</h3>
<p>이미 회원이 있는 서비스에서 policy를 바로 바꾸면 로그인 실패나 링크 단절을 만들 수 있습니다. 4단계로 나누는 편이 낫습니다.</p>
<ol>
<li><strong>관측(1~2주)</strong>: 기존 값에 새 canonical 함수를 계산만 하고 충돌·변환·거부 후보를 기록합니다. 기존 판정은 바꾸지 않습니다.</li>
<li><strong>신규 생성 강제</strong>: 새 계정과 새 slug에만 새 allowlist와 unique constraint를 적용합니다.</li>
<li><strong>고위험 경로 전환</strong>: 권한 allowlist, 관리자 로그인, 외부 callback은 canonical policy version이 확인된 값만 받습니다.</li>
<li><strong>기존 데이터 정리</strong>: 충돌 0건 또는 owner 검토 완료인 묶음부터 작은 batch로 이전합니다. redirect와 rollback 기간을 함께 둡니다.</li>
</ol>
<p>운영 수치는 서비스 성격에 맞춰 조정하되, 시작 기준은 명확해야 합니다. 새 정책 shadow 기간의 collision candidate가 전체 활성 식별자의 **0.01%**를 넘으면 자동 전환을 멈추고 표본을 분석합니다. 로그인 canonicalization mismatch는 정상 기준 <strong>0건</strong>을 목표로 하며, 5분 내 1건이라도 나타나면 해당 변환을 fail-open으로 확대하지 말고 원인 확인으로 전환합니다. 신규 slug validation reject rate가 배포 전 기준선보다 <strong>0.5%p</strong> 이상 오르면 문구·클라이언트 인코딩·정책 범위를 함께 점검합니다.</p>
<h3 id="3-로그에는-값보다-결정-근거를-남긴다">3) 로그에는 값보다 결정 근거를 남긴다</h3>
<p>식별자 문제를 디버깅하려고 전체 이메일, 토큰 subject, hostname을 로그에 남기면 개인정보와 보안 위험이 커집니다. 구조화 로그에는 다음처럼 최소한만 남깁니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;event&#34;</span>: <span style="color:#f1fa8c">&#34;identifier_policy_decision&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;identifier_type&#34;</span>: <span style="color:#f1fa8c">&#34;tenant_slug&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;policy_version&#34;</span>: <span style="color:#f1fa8c">&#34;slug-v2&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;decision&#34;</span>: <span style="color:#f1fa8c">&#34;rejected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;reason_code&#34;</span>: <span style="color:#f1fa8c">&#34;non_ascii_disallowed&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;canonical_hash&#34;</span>: <span style="color:#f1fa8c">&#34;sha256:...&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;request_id&#34;</span>: <span style="color:#f1fa8c">&#34;...&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>원본값이 꼭 필요한 분쟁 조사라면 일반 애플리케이션 로그가 아니라 접근 권한, 보존 기간, 조회 사유가 있는 암호화된 audit store에 둡니다. <a href="/learning/deep-dive/deep-dive-structured-logging/">구조화 로깅</a>의 원칙처럼 관측성은 많이 남기는 일이 아니라, 사고 때 필요한 질문에 안전하게 답할 수 있게 남기는 일입니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>ASCII allowlist는 안전하지만 제품 범위를 좁힙니다.</strong> 사용자 표시 이름까지 ASCII로 제한하면 국제화 사용자를 배제할 수 있습니다. security key와 display value의 책임을 분리하는 이유입니다.</li>
<li><strong>과한 정규화는 다른 사람을 같은 사람으로 만들 수 있습니다.</strong> 호환성 정규화·case folding을 넓게 적용하기 전에는 기존 데이터 collision 보고서와 외부 IdP 왕복 테스트가 필요합니다.</li>
<li><strong>email provider 규칙을 추측하면 안 됩니다.</strong> 점 제거, <code>+tag</code> 제거, 대소문자 접기는 provider·조직 정책에 따라 의미가 다릅니다. 자체 계정 병합 규칙으로 일반화하지 않습니다.</li>
<li><strong>로그 해시도 맥락에 따라 식별 가능할 수 있습니다.</strong> 작은 후보 집합의 email·slug는 hash만으로도 추측될 수 있으므로 salt, 접근 제어, 보존 기간을 함께 설계합니다.</li>
<li><strong>규칙 변경은 URL과 외부 계약을 바꿉니다.</strong> slug를 고치면 bookmark, OAuth callback, webhook endpoint, cache key가 함께 영향을 받을 수 있습니다. 이전은 데이터 작업이 아니라 API 변경으로 취급합니다.</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 로그인 ID, 표시 이름, 외부 subject, hostname allowlist가 서로 다른 데이터 분류로 문서화돼 있다.</li>
<li><input disabled="" type="checkbox"> 보안 식별자는 원본·display·canonical·내부 ID 중 어떤 값을 비교하는지 코드와 스키마에서 확인할 수 있다.</li>
<li><input disabled="" type="checkbox"> canonical column에 unique constraint가 있고, unique violation을 안정적인 409 또는 idempotent 결과로 처리한다.</li>
<li><input disabled="" type="checkbox"> Unicode 변환·대소문자 규칙·external provider 원문 보존 규칙에 policy version이 있다.</li>
<li><input disabled="" type="checkbox"> 새 정책은 shadow collision 관측과 수동 검토 없이 기존 계정에 일괄 적용하지 않는다.</li>
<li><input disabled="" type="checkbox"> authorization은 canonical 문자열이 아니라 resolve된 내부 ID와 principal 관계를 확인한다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<ol>
<li>현재 서비스의 문자열 식별자 10개를 골라 display, lookup, authorization, external opaque ID 중 어디에 속하는지 표로 분류해 보세요.</li>
<li>tenant slug 하나에 대해 허용 문자, 길이, canonical 함수, unique index, rename redirect, rollback 기간을 1쪽 정책으로 작성해 보세요.</li>
<li>Unicode 조합 문자, 대소문자, 공백, confusable 문자, 동일 ID 동시 생성 요청을 포함한 테스트 벡터 15개를 만들고, 기대 결과와 audit reason code를 붙여 보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-spring-validation/">Spring Validation</a></li>
<li><a href="/learning/deep-dive/deep-dive-oauth2-oidc/">OAuth2·OIDC</a></li>
<li><a href="/learning/deep-dive/deep-dive-object-level-authorization-bola-playbook/">BOLA 방지와 객체 수준 인가</a></li>
<li><a href="/learning/deep-dive/deep-dive-structured-logging/">구조화 로깅</a></li>
</ul>
]]></content:encoded></item></channel></rss>