<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Twinkle Blog</title>
  <link href="https://blog.twinklelabs.kr/" rel="alternate"/>
  <link href="https://blog.twinklelabs.kr/feeds/all.atom.xml" rel="self"/>
  <id>https://blog.twinklelabs.kr/</id>
  <updated>2026-09-02T13:30:00.000Z</updated>
  <subtitle>안드로이드 앱을 기획하고 출시하며 배운 것을 기록합니다. 성공보다 오래 남는 실패의 이유와 다시 다듬은 과정까지.</subtitle>
  <entry>
    <title>두 번째 앱 출시기 3 — 세 저장소가 나눠 든 딥링크 - /app/&lt;제품&gt; 단일 규칙과 assetlinks</title>
    <link href="https://blog.twinklelabs.kr/pocket-pdf/app-links-three-repos-deep-link.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/pocket-pdf/app-links-three-repos-deep-link.html</id>
    <published>2026-09-02T13:30:00.000Z</published>
    <updated>2026-09-02T13:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Pocket PDF"/>
    <category term="android"/>
    <category term="pocket-pdf"/>
    <category term="딥링크"/>
    <category term="AppLinks"/>
    <category term="웹"/>
    <summary type="html">QR 코드 하나를 찍었을 때 앱이 있으면 즉시 실행되고, 없으면 소개 웹을 거쳐 스토어로 가야 합니다. 안드로이드 앱, 브랜드 홈페이지, 도메인 문패(assetlinks) 세 저장소가 /app/&lt;제품&gt; 단일 문법으로 질서를 찾은 여정입니다.</summary>
    <content type="html">&lt;p&gt;첫 번째 앱(Stock Calculator)에 이어 Pocket PDF까지 앱이 두 개로 늘어나자, 공유 링크와 QR 코드 관리에서 구조적인 문제가 드러났습니다.&lt;/p&gt;
&lt;p&gt;포스터나 웹사이트에 들어가는 QR 코드에 Google Play 스토어 직링크(&lt;code&gt;https://play.google.com/store/apps/details?id=...&lt;/code&gt;)를 박아두면 다음과 같은 치명적인 한계가 생깁니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;앱이 이미 설치된 사용자도 무조건 스토어로 이동합니다. (앱이 바로 열리지 않음)&lt;/li&gt;
&lt;li&gt;트래픽 분석(&lt;code&gt;utm_source&lt;/code&gt;)을 정교하게 제어할 수 없습니다.&lt;/li&gt;
&lt;li&gt;스토어 주소나 패키지명이 바뀌었을 때 이미 인쇄된 QR 코드를 수정할 수 없습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;우리는 우리만의 도메인(&lt;a href=&quot;https://twinklelabs.kr&quot;&gt;twinklelabs.kr&lt;/a&gt;)을 앞세운 Android App Links 기반 스마트 라우팅을 구축하기로 했습니다.&lt;/p&gt;
&lt;h1 id=&quot;주소의-문법은-저장소-바구니가-결정했습니다&quot;&gt;주소의 문법은 저장소 바구니가 결정했습니다&lt;/h1&gt;
&lt;p&gt;처음에는 딥링크 경로를 &lt;code&gt;/stock-calculator/app&lt;/code&gt;나 &lt;code&gt;/pocket-pdf/download&lt;/code&gt;처럼 앱마다 제각각 지었습니다.&lt;/p&gt;
&lt;p&gt;하지만 제품이 계속 늘어나는 구조에서 이런 불규칙한 경로는 유지보수의 재앙이 됩니다.&lt;/p&gt;
&lt;p&gt;우리는 모노레포와 저장소 구조의 &lt;code&gt;app/&lt;/code&gt; 바구니 규칙을 URL에도 그대로 적용했습니다:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;txt&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;https://twinklelabs.kr/app/&amp;#x3C;제품_슬러그&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Stock Calculator: &lt;code&gt;https://twinklelabs.kr/app/stock-calculator&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Pocket PDF: &lt;code&gt;https://twinklelabs.kr/app/pocket-pdf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;규칙이 단순해지자, 새 앱이 출시될 때마다 어디에 줄을 서야 하는지가 명확해졌습니다.&lt;/p&gt;
&lt;h1 id=&quot;세-저장소가-한-문법을-나누어-집니다&quot;&gt;세 저장소가 한 문법을 나누어 집니다&lt;/h1&gt;
&lt;p&gt;이 딥링크가 제대로 작동하려면 세 개의 서로 다른 저장소가 완벽한 삼위일체로 맞물려야 합니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Android 앱 저장소 (&lt;code&gt;pocket-pdf&lt;/code&gt;):
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AndroidManifest.xml&lt;/code&gt;에 &lt;code&gt;autoVerify=&quot;true&quot;&lt;/code&gt; 인텐트 필터 선언.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;https://twinklelabs.kr/app/pocket-pdf&lt;/code&gt; 경로를 수신하도록 등록.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;브랜드 웹사이트 (&lt;code&gt;home&lt;/code&gt;):
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/app/pocket-pdf&lt;/code&gt; 경로로 접속했을 때, 웹 브라우저 사용자에게 예쁜 랜딩 페이지를 보여주고 스토어로 자연스럽게 넘겨주는 리다이렉트 페이지 구현.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;도메인 루트 문패 (&lt;code&gt;.well-known/assetlinks.json&lt;/code&gt;):
&lt;ul&gt;
&lt;li&gt;Google Play 콘솔의 앱 서명 키 인증서 지문(SHA-256)을 등록하여 안드로이드 OS가 우리 도메인의 소유권을 공식 인증하도록 선언.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;[&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;    &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;relation&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;delegate_permission/common.handle_all_urls&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;    &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;target&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;      &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;namespace&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;android_app&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;      &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;package_name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;kr.twinklelabs.pocketpdf&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;      &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;sha256_cert_fingerprints&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;8D:04:71:...&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;결과-어떤-상황에서도-가장-매끄러운-경험&quot;&gt;결과: 어떤 상황에서도 가장 매끄러운 경험&lt;/h1&gt;
&lt;p&gt;이제 사용자가 QR 코드를 찍거나 링크를 누르면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pocket PDF 앱이 설치된 폰: 0.1초 만에 앱의 메인 화면이 즉시 실행됩니다.&lt;/li&gt;
&lt;li&gt;앱이 설치되지 않은 폰/PC: &lt;code&gt;twinklelabs.kr/app/pocket-pdf&lt;/code&gt; 소개 화면이 뜨고, 사용자의 환경에 맞는 다운로드 버튼(Play Store 직행)이 열립니다.&lt;/li&gt;
&lt;li&gt;마케팅 추적: 웹 랜딩 페이지가 &lt;code&gt;utm_source&lt;/code&gt;와 꼬리표를 깔끔하게 달아 스토어로 넘겨줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;p&gt;Stock Calculator가 “안드로이드 앱을 처음으로 끝까지 만들어 본 경험”이었다면, Pocket PDF는 “여러 앱과 웹 플랫폼이 하나의 생태계로 엮이는 질서를 세운 경험”이었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;엔진은 공통 모듈로 정제되어 한글 OCR을 완벽히 읽어냈고,&lt;/li&gt;
&lt;li&gt;BM은 필수 도구를 인질 잡지 않고 투명해졌으며,&lt;/li&gt;
&lt;li&gt;딥링크는 단일 URL 문법 아래 세 저장소를 단단히 묶었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하나를 만들 때는 몰랐던 것들이, 둘을 만들면서 비로소 제자리를 찾아갑니다.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>두 번째 앱 출시기 2 — 파는 것이 바뀌면 이름도 따라갑니다 - PRO BM과 도구 전면 개방</title>
    <link href="https://blog.twinklelabs.kr/pocket-pdf/pro-monetization-unlocked-tools.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/pocket-pdf/pro-monetization-unlocked-tools.html</id>
    <published>2026-09-02T13:20:00.000Z</published>
    <updated>2026-09-02T13:20:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Pocket PDF"/>
    <category term="android"/>
    <category term="pocket-pdf"/>
    <category term="BM"/>
    <category term="인앱결제"/>
    <category term="수익화"/>
    <summary type="html">서명, 페이지 순서 편집, 병합 같은 핵심 기능들을 유료 결제 뒤로 잠가두려다 생각을 바꿨습니다. 모든 도구를 전면 무료로 열고, PRO 결제는 '광고 제거 + 초고화질 내보내기'로 단순화했습니다. 파는 것이 바뀌면 화면의 말도 바뀌어야 합니다.</summary>
    <content type="html">&lt;p&gt;유틸리티 앱의 비즈니스 모델(BM)을 기획할 때 가장 쉽게 빠지는 유혹이 있습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“페이지 회전은 무료로 두고, 페이지 순서 바꾸기나 PDF 서명, 분할 기능은 PRO 전용으로 잠가두자.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;처음의 Pocket PDF 기획서에도 화면마다 자물쇠 아이콘이 달려 있었고, 사용자가 조금만 유용한 버튼을 누르면 인앱 결제 바텀시트가 불쑥 튀어나왔습니다.&lt;/p&gt;
&lt;p&gt;하지만 직접 앱을 써보며 강한 거부감이 들었습니다. 기본적인 도구를 쓰려고 할 때마다 통행세를 요구하는 앱은 사용자에게 스트레스만 남깁니다.&lt;/p&gt;
&lt;h1 id=&quot;모든-도구를-무료로-전면-개방하다&quot;&gt;모든 도구를 무료로 전면 개방하다&lt;/h1&gt;
&lt;p&gt;우리는 과감한 결정을 내렸습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모든 편집 기능 100% 무료 개방:
&lt;ul&gt;
&lt;li&gt;서명(Signature) 삽입&lt;/li&gt;
&lt;li&gt;페이지 순서 재배치 및 회전&lt;/li&gt;
&lt;li&gt;PDF 병합 및 분할&lt;/li&gt;
&lt;li&gt;이미지 텍스트 OCR 글자층 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;PRO 결제가 파는 것의 단일화:
&lt;ul&gt;
&lt;li&gt;광고 전면 제거 (Ad-Free)&lt;/li&gt;
&lt;li&gt;초고화질 무손실 원본 내보내기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;사용자는 결제를 하지 않아도 앱의 모든 강력한 기능을 마음껏 쓸 수 있습니다. 대신 쾌적하게 광고 없이 빠르게 쓰고 싶은 분들과 전문적인 고화질 인쇄가 필요한 분들만 기분 좋게 결제하도록 설계했습니다.&lt;/p&gt;
&lt;h1 id=&quot;파는-것이-바뀌면-화면의-말도-따라가야-합니다&quot;&gt;파는 것이 바뀌면 화면의 말도 따라가야 합니다&lt;/h1&gt;
&lt;p&gt;도구들이 무료로 다 열리고 나자, 인앱 결제 화면에 적혀 있던 문구가 거짓말이 되었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;✗ “PRO로 업그레이드하고 모든 고급 편집 도구를 잠금 해제하세요!”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;도구는 이미 다 열려 있는데, 결제 팝업은 여전히 과거의 문구를 읊고 있었습니다.&lt;/p&gt;
&lt;p&gt;상품의 내부 식별자(Product ID)는 원래부터 &lt;code&gt;ads_removal&lt;/code&gt;이었습니다.
우리는 안내 문구를 상품 본질에 맞게 정직하게 다시 썼습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;○ “광고 없는 쾌적한 환경과 원본 화질을 평생 이용하세요.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이름과 파는 것이 일치하지 않는 앱은 신뢰를 잃습니다. 제품의 판매 정책이 바뀌었다면 버튼의 레이블부터 다이얼로그의 설명문까지 전부 그 자리에 맞춰 다시 써야 합니다.&lt;/p&gt;
&lt;h1 id=&quot;복잡한-구독-티어를-단순한-단판-결제로&quot;&gt;복잡한 구독 티어를 단순한 단판 결제로&lt;/h1&gt;
&lt;p&gt;초기에는 ‘월 ₩1,900 / 연 ₩14,900 / 평생 ₩19,900’의 3단계 복잡한 구독 구조를 두었습니다.
하지만 작은 유틸리티 앱에서 구독을 관리하는 피로감은 사용자의 결제 전환율을 떨어뜨립니다.&lt;/p&gt;
&lt;p&gt;결국 가장 명쾌한 ‘평생 소장 1회 구매’ 중심으로 결제 동선을 정리했습니다.
사용자는 언제 결제될지 모르는 정기 결제의 불안감 없이, 한 번의 구매로 평생 깨끗한 앱을 소유하게 됩니다.&lt;/p&gt;
&lt;h2 id=&quot;다음-작업을-위한-메모&quot;&gt;다음 작업을 위한 메모&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;유틸리티의 본질은 도구의 효용입니다. 필수 기능을 인질로 잡는 수익화는 오래가지 못합니다.&lt;/li&gt;
&lt;li&gt;파는 것의 정의가 바뀌면 화면의 문장도 즉시 고쳐야 합니다.&lt;/li&gt;
&lt;li&gt;단순하고 정직한 가격 정책이 복잡한 할인 플랜보다 강합니다.&lt;/li&gt;
&lt;li&gt;다음 편에서는 앱, 웹 사이트, 도메인 문패가 &lt;code&gt;/app/&amp;#x3C;제품&gt;&lt;/code&gt; 단일 규격으로 통일되는 딥링크 구축기를 다룹니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>두 번째 앱 출시기 1 — 한글이 검색되는 PDF 만들기 - OCR 인식 좌표와 글자층</title>
    <link href="https://blog.twinklelabs.kr/pocket-pdf/searchable-pdf-ocr-layer.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/pocket-pdf/searchable-pdf-ocr-layer.html</id>
    <published>2026-09-02T13:10:00.000Z</published>
    <updated>2026-09-02T13:10:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Pocket PDF"/>
    <category term="android"/>
    <category term="pocket-pdf"/>
    <category term="OCR"/>
    <category term="PDF"/>
    <category term="엔진"/>
    <summary type="html">첫 번째 앱 Stock Calculator에 이어 두 번째 앱 Pocket PDF를 만들었습니다. 이미지를 PDF로 변환하면서 텍스트를 인식해 투명 글자층으로 얹는 핵심 엔진 개발과 한글 유니코드 문제를 해결한 과정을 정리합니다.</summary>
    <content type="html">&lt;p&gt;첫 번째 앱인 물타기 계산기(Stock Calculator)를 플레이 스토어 테스트 트랙에 올린 뒤, 곧바로 두 번째 제품인 ‘Pocket PDF’ 개발에 착수했습니다.&lt;/p&gt;
&lt;p&gt;스마트폰 카메라로 문서를 촬영하거나 영수증, 계약서 이미지를 불러와 깨끗한 PDF 문서로 만들고, 이미지 안의 글자를 검색하고 복사할 수 있게 해주는 휴대용 PDF 유틸리티입니다.&lt;/p&gt;
&lt;p&gt;첫 번째 편에서는 Pocket PDF의 가장 핵심이자 가장 큰 기술적 도전이었던 ‘검색 가능한 PDF(Searchable PDF) 엔진’ 구축 과정을 다룹니다.&lt;/p&gt;
&lt;h1 id=&quot;이미지-위에-보이지-않는-글자-올리기&quot;&gt;이미지 위에 보이지 않는 글자 올리기&lt;/h1&gt;
&lt;p&gt;종이 문서 사진을 단순 PDF로 묶는 것은 쉽습니다. 하지만 사용자가 기대하는 진짜 디지털 문서는 “원하는 단어를 검색(Ctrl+F)하고, 손가락으로 긁어 복사할 수 있는 문서”입니다.&lt;/p&gt;
&lt;p&gt;이를 구현하는 핵심 원리는 다음과 같습니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ML Kit OCR 엔진 실행: 이미지에서 텍스트 블록, 라인, 단어들의 바운딩 박스(Bounding Box) 좌표와 인식된 문자열을 추출합니다.&lt;/li&gt;
&lt;li&gt;좌표계 스케일링: 원본 이미지의 픽셀 좌표를 PDF 페이지의 포인트(Point, 72 DPI) 좌표계로 정확히 변환합니다.&lt;/li&gt;
&lt;li&gt;투명 텍스트 레이어 렌더링 (&lt;code&gt;InvisibleTextLayer&lt;/code&gt;): 배경에는 스캔 이미지를 그대로 그리고, 그 위에 투명 렌더링 모드(&lt;code&gt;RenderingMode.NEITHER_FILL_NOR_STROKE&lt;/code&gt;)로 글자를 정확한 위치에 얹습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id=&quot;흩어져-있던-좌표-계산을-한곳으로-모으다&quot;&gt;흩어져 있던 좌표 계산을 한곳으로 모으다&lt;/h1&gt;
&lt;p&gt;처음에는 ‘이미지 → PDF 변환기’와 ‘실시간 문서 스캐너’ 두 곳에서 각자 좌표 계산 함수를 들고 있었습니다.&lt;/p&gt;
&lt;p&gt;두 화면의 코드를 하나로 모으는 리팩토링을 진행하다가 충격적인 버그를 발견했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// InvisibleTextLayer 내부의 문자 정제 함수&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; sanitize&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(text: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // 과거 누군가 넣어둔 영문 전용 필터&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; text.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { char &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (char.code &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 256&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) char &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; ' '&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;joinToString&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;‘코드값 256 이상의 문자를 전부 공백으로 바꾼다’. 영문 ASCII만 염두에 두고 짰던 코드가 그대로 남아 있었습니다.&lt;/p&gt;
&lt;p&gt;한글 유니코드는 &lt;code&gt;U+AC00&lt;/code&gt;부터 시작합니다. 화면에서는 분명히 OCR이 끝나 검색 가능한 PDF가 만들어졌다고 알렸지만, 한국 주 시장에서 한글은 단 한 글자도 검색되지 않고 있었던 것입니다.&lt;/p&gt;
&lt;p&gt;중복 코드를 걷어내고 &lt;code&gt;InvisibleTextLayer&lt;/code&gt; 단일 모듈로 통일하지 않았다면 영원히 발견하지 못했을 버그였습니다.&lt;/p&gt;
&lt;h1 id=&quot;tounicode-매핑과-380kb-초경량-폰트&quot;&gt;ToUnicode 매핑과 380KB 초경량 폰트&lt;/h1&gt;
&lt;p&gt;버그를 잡은 뒤에도 문제는 끝나지 않았습니다. 앞선 안드로이드 연재 11편에서 다룬 것처럼, PDF 뷰어가 한글을 인식하려면 올바른 폰트의 &lt;code&gt;/ToUnicode&lt;/code&gt; CMap이 필요했습니다.&lt;/p&gt;
&lt;p&gt;우리는 Pretendard(OFL) 폰트에서 현대 한글 2,350자와 필수 기호만 추출하는 &lt;code&gt;tools/bake-ocr-font.py&lt;/code&gt; 스크립트를 작성하여 380KB의 초경량 OTF 서브셋을 구워 패키징했습니다.&lt;/p&gt;
&lt;p&gt;그리고 JVM 상에서 &lt;code&gt;pdfbox-android&lt;/code&gt;로 생성된 PDF의 ToUnicode를 역추출하는 단위 테스트를 작성해 완벽한 한글 검색을 최종 증명했습니다.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;중복을 제거하는 진짜 가치는 코드 감소가 아니라 ‘숨은 거짓말을 발견하는 것’입니다. 두 군데로 나뉘어 있을 때는 아무도 그 함수를 다시 읽지 않았습니다.&lt;/li&gt;
&lt;li&gt;기능이 동작하는 척하는 것과 진짜 되는 것은 다릅니다. OCR 좌표가 맞고 폰트가 실렸어도, 뷰어의 역추출 테스트를 통과하기 전까지는 완성된 것이 아닙니다.&lt;/li&gt;
&lt;li&gt;다음 편에서는 복잡하게 꼬여 있던 인앱 결제와 광고 수익화 모델을 단순하고 정직하게 뜯어고친 과정을 다룹니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>디자인 시스템 세우기 4 — light-dark()를 퍼센트 자리에 쓰면 선언이 통째로 죽습니다</title>
    <link href="https://blog.twinklelabs.kr/design-system/light-dark-percentage.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/design-system/light-dark-percentage.html</id>
    <published>2026-09-02T13:00:00.000Z</published>
    <updated>2026-09-02T13:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Design System"/>
    <category term="CSS"/>
    <category term="디자인시스템"/>
    <category term="웹"/>
    <category term="토큰"/>
    <category term="color-mix"/>
    <summary type="html">CSS의 모던 함수 light-dark()를 color-mix()의 퍼센트 농도 자리에 썼더니 에러 하나 없이 선언 전체가 무효 처리되어 투명해졌습니다. CSS 명세의 미묘한 파싱 규칙과 테마 토큰을 안전하게 합성하는 방법을 정리합니다.</summary>
    <content type="html">&lt;p&gt;Aurora Ledger 웹 컴포넌트들을 다듬으면서 옅은 배경색(&lt;code&gt;--wash&lt;/code&gt; · &lt;code&gt;--primary-wash&lt;/code&gt;)과 상태 배지 알약의 배경을 정의하고 있었습니다.&lt;/p&gt;
&lt;p&gt;라이트 모드에서는 글자색의 &lt;code&gt;8%&lt;/code&gt;, 다크 모드에서는 &lt;code&gt;12%&lt;/code&gt; 농도로 은은하게 배경을 깔아주고 싶었습니다.
CSS의 최신 함수인 &lt;code&gt;color-mix()&lt;/code&gt;와 &lt;code&gt;light-dark()&lt;/code&gt;를 조합해 아주 우아하게 한 줄로 적었다고 생각했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* 겉보기엔 완벽해 보였던 한 줄 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;--wash: color-mix(in srgb&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; var(--text) light-dark(8%&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; 12%), transparent);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 화면을 띄워보니 배지와 카드의 워시 배경이 처음부터 단 한 번도 그려지지 않고 있었습니다.&lt;/p&gt;
&lt;p&gt;콘솔에 에러도 안 뜨고, CSS 린터도 조용했습니다.&lt;/p&gt;
&lt;h1 id=&quot;브라우저는-왜-아무-말-없이-투명으로-넘겼을까&quot;&gt;브라우저는 왜 아무 말 없이 투명으로 넘겼을까?&lt;/h1&gt;
&lt;p&gt;원인은 CSS 표준 명세(W3C Color Module Level 5)의 엄격한 문법 규칙에 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;light-dark()&lt;/code&gt; 함수는 &lt;code&gt;&amp;#x3C;color&gt;&lt;/code&gt; 데이터 타입 자리에만 올 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;light-dark( &amp;#x3C;color&gt; , &amp;#x3C;color&gt; )&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 우리가 작성한 코드에서 &lt;code&gt;light-dark(8%, 12%)&lt;/code&gt;는 색상이 아니라 &lt;code&gt;&amp;#x3C;percentage&gt;&lt;/code&gt; 자리에 들어가 있었습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;브라우저 CSS 파서는 퍼센트 자리에 색상 함수(&lt;code&gt;light-dark&lt;/code&gt;)가 들어온 것을 발견합니다.&lt;/li&gt;
&lt;li&gt;값 전체를 파싱할 수 없는 잘못된 선언(Invalid Declaration)으로 판정합니다.&lt;/li&gt;
&lt;li&gt;CSS 파서는 에러를 던져 웹페이지를 멈추지 않고, 해당 스타일 속성을 조용히 무시(Ignore)하고 넘어갑니다.&lt;/li&gt;
&lt;li&gt;결과적으로 &lt;code&gt;--wash&lt;/code&gt; 변수는 정의되지 않은 상태가 되어 투명(transparent)으로 렌더링되었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id=&quot;바른-해결책-color-mix를-두-번-접어-넣기&quot;&gt;바른 해결책: &lt;code&gt;color-mix&lt;/code&gt;를 두 번 접어 넣기&lt;/h1&gt;
&lt;p&gt;이 문제를 해결하는 정석적인 방법은 &lt;code&gt;light-dark()&lt;/code&gt; 함수 안에 완성된 &lt;code&gt;&amp;#x3C;color&gt;&lt;/code&gt; 표현식 두 개를 전달하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* 올바른 합성: light-dark()는 색상 자리에만 선다 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;--wash: light-dark(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;  color-mix(in srgb&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; var(--text) 8%, transparent),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;  color-mix(in srgb&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; var(--text) 12%, transparent)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 쓰면 &lt;code&gt;light-dark()&lt;/code&gt;의 첫 번째 인자와 두 번째 인자 모두 명확한 &lt;code&gt;&amp;#x3C;color&gt;&lt;/code&gt; 타입이 되므로 브라우저가 완벽하게 파싱합니다.&lt;/p&gt;
&lt;h1 id=&quot;테마-토큰을-다루며-얻은-교훈&quot;&gt;테마 토큰을 다루며 얻은 교훈&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;CSS 함수는 제 자리가 있습니다: 아무리 강력한 함수라도 스펙이 허용한 슬롯(Type)을 벗어나면 침묵의 무효화가 일어납니다.&lt;/li&gt;
&lt;li&gt;조용한 실패는 눈으로 증명해야 합니다: 브라우저 개발자 도구의 Styles 패널에서 취소선이 그어져 있는지, Computed 패널에서 실제로 연산된 RGBA 값이 찍히는지 확인해야 합니다.&lt;/li&gt;
&lt;li&gt;토큰 합성 유틸리티의 표준화: 혼합 색상(Wash) 토큰을 작성할 때는 개별 컴포넌트에서 매번 &lt;code&gt;color-mix&lt;/code&gt;를 손으로 조합하지 않고, 디자인 시스템 글로벌 레이어에서 규격화된 유틸리티 토큰으로 제공해야 합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;light-dark()&lt;/code&gt;는 오직 색상(&lt;code&gt;&amp;#x3C;color&gt;&lt;/code&gt;) 자리에만 섭니다. 퍼센트나 길이 단위 자리에는 쓸 수 없습니다.&lt;/li&gt;
&lt;li&gt;CSS는 문법 오류가 나도 소리치지 않고 조용히 선언을 버립니다.&lt;/li&gt;
&lt;li&gt;새로운 CSS 명세를 도입할 때는 개발자 도구의 계산된 값(Computed Value)을 반드시 한 번 찍어보세요.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 13 — 검사는 겨눈 것만 셉니다 - 11개 언어와 strings-parity의 맹점</title>
    <link href="https://blog.twinklelabs.kr/android/strings-parity-limits.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/strings-parity-limits.html</id>
    <published>2026-09-02T12:50:00.000Z</published>
    <updated>2026-09-02T12:50:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="안드로이드"/>
    <category term="다국어"/>
    <category term="현지화"/>
    <category term="CI"/>
    <category term="품질"/>
    <summary type="html">11개 언어 리소스의 누락을 잡는 strings-parity.sh 스크립트가 있었습니다. 스크립트는 항상 초록불이었지만, 실제 스페인어 파일에는 영어가 섞여 있고 프랑스어 파일에는 스페인어가 오염되어 있었습니다. 검사는 열쇠만 세지, 그 안의 뜻을 보지 못합니다.</summary>
    <content type="html">&lt;p&gt;글로벌 출시를 위해 11개 언어(한국어, 영어, 일본어, 중국어 간/번체, 스페인어, 독일어, 프랑스어, 이탈리아어, 포르투갈어, 인도네시아어)를 지원하고 있습니다.&lt;/p&gt;
&lt;p&gt;언어가 열한 벌이나 되다 보니 새 기능이 들어갈 때마다 번역 키가 누락되기 십상입니다. 그래서 &lt;code&gt;strings-parity.sh&lt;/code&gt;라는 CI 검증 스크립트를 만들어 돌렸습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# strings-parity.sh의 핵심 로직&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 기본 언어(ko/en)의 모든 &amp;#x3C;string name=&quot;...&quot;&gt; 키가 다른 10개 언어 파일에도 존재하는지 검사&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;매 빌드마다 이 검사는 아주 씩씩하게 초록불(Exit 0)을 띄웠습니다.
“11개 언어 240개 키의 동기화가 완벽하다”고 믿었습니다.&lt;/p&gt;
&lt;h1 id=&quot;스페인어-화면에-영어가-남아-있던-이유&quot;&gt;스페인어 화면에 영어가 남아 있던 이유&lt;/h1&gt;
&lt;p&gt;어느 날 스페인어(&lt;code&gt;values-es/strings.xml&lt;/code&gt;) 리소스를 직접 열어보다가 깜짝 놀랐습니다.&lt;/p&gt;
&lt;p&gt;키는 빠짐없이 다 정의되어 있었지만, 내용물이 이렇게 되어 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;&amp;#x3C;!-- values-es/strings.xml --&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;action_scan_document&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;Scan Document&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;msg_delete_confirm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;¿Estás seguro de que deseas eliminar?&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;btn_upgrade_pro&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;Upgrade to PRO&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;스크립트의 시선: &lt;code&gt;action_scan_document&lt;/code&gt; 키가 존재하는가? → Yes (통과)&lt;/li&gt;
&lt;li&gt;사용자의 시선: 스페인어로 앱을 쓰는데 중요한 버튼만 영어로 나온다 → 번역 누락 (버그)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;strings-parity.sh&lt;/code&gt;는 열쇠(Key)의 존재 여부만 셀 뿐, 그 열쇠가 품은 값(Value)의 오염은 검사하지 못했습니다.&lt;/p&gt;
&lt;p&gt;기능을 급히 추가할 때 일단 빌드를 통과시키려고 기본 언어 텍스트를 복사해 넣었던 것이 그대로 프로덕션까지 살아남은 것입니다.&lt;/p&gt;
&lt;h1 id=&quot;겹치는-낱말을-기계적으로-지우면-생기는-2차-사고&quot;&gt;겹치는 낱말을 기계적으로 지우면 생기는 2차 사고&lt;/h1&gt;
&lt;p&gt;이 문제를 기계적으로 해결하려고 “기본 언어(영어)와 문자열 값이 100% 일치하면 에러를 뱉자”는 스크립트를 짰다면 또 다른 참사가 났을 것입니다.&lt;/p&gt;
&lt;p&gt;실제로 다국어를 뜯어보면 서로 다른 언어가 완전히 같은 철자의 단어를 쓰는 경우가 많습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;‘Cancelar’: 스페인어와 포르투갈어 모두 취소 버튼으로 사용합니다.&lt;/li&gt;
&lt;li&gt;‘Information’: 영어와 프랑스어(철자 동일)에서 모두 쓰입니다.&lt;/li&gt;
&lt;li&gt;‘PDF’, ‘PRO’, ‘OK’: 모든 언어에서 동일하게 유지되어야 하는 공통 명사입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;만약 단순 텍스트 일치율로 기계적 필터링을 걸었다면 멀쩡하고 정확한 번역본까지 모조리 파괴했을 것입니다.&lt;/p&gt;
&lt;h1 id=&quot;번역-빚을-털어내는-3단계-정비-원칙&quot;&gt;번역 빚을 털어내는 3단계 정비 원칙&lt;/h1&gt;
&lt;p&gt;우리는 11개 언어의 번역 빚을 다음과 같은 원칙으로 전수 청산했습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;기계가 찾아주고 사람이 한 줄씩 봅니다: 동일 문자열 후보군을 스크립트로 추출하되, 실제 수정 여부는 언어별 문맥을 아는 사람이 하나씩 대조하며 확정했습니다.&lt;/li&gt;
&lt;li&gt;언어별 독립성 보장: 포르투갈어와 스페인어가 같은 단어를 쓰더라도, 참조 링크로 묶지 않고 각 언어 파일에 명시적으로 독립된 값으로 둡니다.&lt;/li&gt;
&lt;li&gt;더미 값 전용 프리픽스 도입: 미번역 임시 문자열에는 &lt;code&gt;TODO_TRANSLATE_&lt;/code&gt; 같은 명시적 마킹을 붙여 CI가 키뿐만 아니라 플레이스홀더를 감지하도록 개선했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;검사는 겨눈 것만 셉니다. 키가 있다고 해서 번역이 된 것은 아닙니다.&lt;/li&gt;
&lt;li&gt;기계적인 검증 스크립트는 뼈대를 지켜줄 뿐, 콘텐츠의 질감과 정확성은 사람이 직접 읽어야 합니다.&lt;/li&gt;
&lt;li&gt;겉보기에 정갈한 XML 파일 뒤에 숨은 ‘복사-붙여넣기의 잔재’를 정기적으로 감사(Audit)하세요.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 12 — 광고가 이상한 줄 알았더니 위의 Scaffold가 둘이었어요</title>
    <link href="https://blog.twinklelabs.kr/android/two-scaffolds-bug.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/two-scaffolds-bug.html</id>
    <published>2026-09-02T12:40:00.000Z</published>
    <updated>2026-09-02T12:40:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="안드로이드"/>
    <category term="Compose"/>
    <category term="Scaffold"/>
    <category term="인셋"/>
    <category term="UI버그"/>
    <summary type="html">하단 광고 배너 아래에 정체불명의 빈 여백이 생겼습니다. 광고 뷰의 픽셀을 의심했지만, 원인은 화면 상단에 겹쳐 있던 2중 Scaffold와 두 번 더해진 윈도우 인셋이었습니다. 눈에 보이는 자리와 원인이 있는 자리를 분리해 디버깅한 기록입니다.</summary>
    <content type="html">&lt;p&gt;화면 하단에 AdMob 적응형 배너를 붙였는데, 기기 하단 내비게이션 바와 광고 사이에 정체불명의 빈 띠(여백)가 생겼습니다.&lt;/p&gt;
&lt;p&gt;처음에는 누구나 광고 컴포넌트를 의심합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“AdMob SDK의 Adaptive Banner 높이 계산 공식이 틀렸나?”&lt;/li&gt;
&lt;li&gt;“광고를 감싼 &lt;code&gt;AndroidView&lt;/code&gt;의 &lt;code&gt;layout_height&lt;/code&gt;가 &lt;code&gt;wrap_content&lt;/code&gt;라서 그런가?”&lt;/li&gt;
&lt;li&gt;“기기 화면 해상도(DPI) 변환 과정에서 오차가 났나?”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실제로 광고 컨테이너의 픽셀을 레이아웃 인스펙터로 찍어보았습니다.
&lt;code&gt;Box&lt;/code&gt;, &lt;code&gt;FrameLayout&lt;/code&gt;, &lt;code&gt;AdView&lt;/code&gt; 모두 정확히 &lt;code&gt;1078 × 168 px&lt;/code&gt;을 가리키고 있었습니다. 광고는 1픽셀의 오차도 없이 완벽하게 제 크기를 지키고 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;빈-띠를-만든-것은-광고-위의-2중-scaffold&quot;&gt;빈 띠를 만든 것은 광고 위의 2중 Scaffold&lt;/h1&gt;
&lt;p&gt;문제는 광고 뷰 자체가 아니라, 화면 트리를 감싸고 있던 상위 구조에 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 최상위 앱 내비게이션 레벨&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;Scaffold&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    bottomBar &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* ... */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) { paddingValues &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // 각 탭별 개별 화면 레벨&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    Scaffold&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        modifier &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;padding&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(paddingValues) &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 1차 시스템 인셋 적용&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    ) { innerPadding &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;        // 콘텐츠 화면&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;        Column&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(modifier &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;padding&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(innerPadding)) { &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 2차 시스템 인셋 또 적용!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;            ..&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;            AdBanner&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compose에서 &lt;code&gt;Scaffold&lt;/code&gt;는 시스템 윈도우 인셋(네비게이션 바, 상태 표시줄 높이)을 계산해 &lt;code&gt;innerPadding&lt;/code&gt;으로 넘겨줍니다.&lt;/p&gt;
&lt;p&gt;그런데 메인 프레임에 &lt;code&gt;Scaffold&lt;/code&gt;가 있고, 그 자식 화면 안에 또 &lt;code&gt;Scaffold&lt;/code&gt;를 선언하면서 하단 시스템 인셋(Navigation Bar Inset)이 두 번 더해지고 있었던 것입니다.&lt;/p&gt;
&lt;p&gt;광고 밑의 빈 띠는 광고가 밀어낸 것이 아니라, 두 겹의 Scaffold가 만들어낸 유령 여백이었습니다.&lt;/p&gt;
&lt;h1 id=&quot;하나를-찾으면-나머지를-세는-일이-뒤따릅니다&quot;&gt;하나를 찾으면 나머지를 세는 일이 뒤따릅니다&lt;/h1&gt;
&lt;p&gt;이 버그를 잡고 나서 곧바로 화면 전체를 훑었습니다.
경험상 이런 구조적 실수는 단 한 화면에만 머물지 않기 때문입니다.&lt;/p&gt;
&lt;p&gt;전수 조사를 해보니 유사한 ‘화면 복제 버그 4종 세트’가 곳곳에 숨어 있었습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;뷰어 뒤로가기 시트 재노출 버그: PDF 뷰어에서 뒤로가기 키를 눌러 나왔을 때 결과 바텀시트가 닫히지 않고 다시 뜨는 현상 (4개 화면)&lt;/li&gt;
&lt;li&gt;하단 인셋 2중첩 여백: 바텀시트와 화면 본문에서 시스템 패딩이 2중으로 먹던 현상 (3개 화면)&lt;/li&gt;
&lt;li&gt;빈 상태(Empty State) 정렬 어긋남: 데이터가 없을 때 표시되는 일러스트가 위로 치우치던 현상 (2개 화면)&lt;/li&gt;
&lt;li&gt;회전 시 아이콘 상태 꼬임: 기기 회전 시 툴바 액션 아이콘의 상태가 초기화되던 현상&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;하나를 고쳤을 때 “혹시 다른 화면에도 이 패턴이 있는가?”를 전수 조사하지 않으면, 우리는 버그의 25%만 고치고 퇴근하는 셈이 됩니다.&lt;/p&gt;
&lt;h1 id=&quot;눈에-보이는-자리와-원인이-있는-자리를-분리하기&quot;&gt;눈에 보이는 자리와 원인이 있는 자리를 분리하기&lt;/h1&gt;
&lt;p&gt;UI 디버깅에서 가장 위험한 함정은 “문제가 보이는 시각적 위치에 원인 코드가 있을 것”이라는 선입견입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;하단 광고가 이상해 보인다고 광고 뷰의 패딩을 마이너스로 깎는 행위&lt;/li&gt;
&lt;li&gt;바텀시트가 겹친다고 임의의 &lt;code&gt;Spacer(height = 16.dp)&lt;/code&gt;를 쑤셔 넣는 행위&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 처방은 근본 원인(2중 Scaffold)을 가려버리고, 나중에 다른 기기나 화면 크기에서 레이아웃이 완전히 깨지는 2차 재앙을 낳습니다.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;보이는 자리와 원인이 있는 자리는 다릅니다. 픽셀을 측정하여 결백한 컴포넌트를 먼저 증명하세요.&lt;/li&gt;
&lt;li&gt;Compose에서 &lt;code&gt;Scaffold&lt;/code&gt;를 중첩할 때는 &lt;code&gt;WindowInsets&lt;/code&gt; 전달 경로와 &lt;code&gt;paddingValues&lt;/code&gt; 소비 여부를 엄격히 확인해야 합니다.&lt;/li&gt;
&lt;li&gt;버그를 하나 잡았다면 반드시 전체 저장소에서 같은 패턴을 검색하세요. 하나가 발견된 자리는 보통 네 군데로 복제되어 있습니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 11 — 글자층을 싣는 것과 검색이 되는 것은 다른 말입니다</title>
    <link href="https://blog.twinklelabs.kr/android/ocr-font-baking.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/ocr-font-baking.html</id>
    <published>2026-09-02T12:30:00.000Z</published>
    <updated>2026-09-02T12:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="안드로이드"/>
    <category term="PDF"/>
    <category term="OCR"/>
    <category term="폰트"/>
    <category term="Pretendard"/>
    <summary type="html">이미지 스캔본 위에 투명 텍스트를 얹어 검색 가능한 PDF를 만들었습니다. 그런데 글꼴 파일이 PDF에 들어갔다는 것과 뷰어에서 실제로 검색이 된다는 것은 전혀 다른 문제였습니다. ToUnicode 맵과 폰트 서브셋 베이킹의 집착을 정리합니다.</summary>
    <content type="html">&lt;p&gt;스캔한 이미지나 사진 문서에서 글자를 추출해 ‘검색 가능한 PDF(Searchable PDF)’를 만드는 기능을 개발하고 있었습니다.&lt;/p&gt;
&lt;p&gt;원리는 간단해 보입니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ML Kit 등으로 이미지 내 글자와 바운딩 박스(Bounding Box) 좌표를 읽는다.&lt;/li&gt;
&lt;li&gt;PDF 페이지 위에 원본 이미지를 깔고, 그 위에 정확한 위치와 크기로 투명 글자층(Invisible Text Layer)을 인쇄한다.&lt;/li&gt;
&lt;li&gt;겉으로는 깨끗한 스캔본으로 보이지만, 사용자가 드래그해서 복사하거나 돋보기로 검색하면 글자가 잡힌다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;그런데 화면에는 분명히 글자층이 올라갔다고 뜨고 파일 용량도 늘어났는데, 한글 검색이 단 한 글자도 되지 않았습니다.&lt;/p&gt;
&lt;h1 id=&quot;폰트가-들어갔는데-왜-검색이-안-될까&quot;&gt;폰트가 들어갔는데 왜 검색이 안 될까?&lt;/h1&gt;
&lt;p&gt;PDF 명세에서 글자가 뷰어에 검색되고 복사되려면 단순히 글리프(외형)를 그리는 것만으로는 부족합니다.&lt;/p&gt;
&lt;p&gt;PDF 리더는 페이지 내 글리프 인덱스를 유니코드 문자로 변환하기 위해 &lt;code&gt;/ToUnicode&lt;/code&gt; CMap(문자 맵)을 참조합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;영문 ASCII(0~127)는 표준 기본 폰트(Helvetica 등)로도 대부분의 리더가 유니코드를 알아챕니다.&lt;/li&gt;
&lt;li&gt;하지만 한글(U+AC00 ~ U+D7A3)은 유니코드 매핑 테이블이 정확히 임베딩된 TrueType/OpenType 폰트가 PDF 바이너리 안에 내장되어 있지 않으면, 뷰어는 해당 자리를 깨진 물음표나 공백으로 인식합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, ‘PDF 안에 폰트가 실렸다’는 것과 ‘뷰어가 그 폰트의 글리프를 유니코드로 읽을 수 있다’는 것은 완전히 다른 이야기였습니다.&lt;/p&gt;
&lt;h1 id=&quot;11개-언어를-다-넣으면-앱이-터집니다&quot;&gt;11개 언어를 다 넣으면 앱이 터집니다&lt;/h1&gt;
&lt;p&gt;처음 든 생각은 “모든 언어를 지원하는 대형 Noto Sans CJK 폰트를 통째로 에셋에 넣자”였습니다.&lt;/p&gt;
&lt;p&gt;하지만 CJK 통합 폰트는 파일 하나에 15MB ~ 30MB에 달합니다. 10MB 남짓한 가벼운 유틸리티 앱에 폰트 하나 때문에 용량이 서너 배로 불어나는 것은 용납할 수 없었습니다.&lt;/p&gt;
&lt;p&gt;여기서 원칙을 세웠습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“폰트가 덮어야 하는 것은 ‘앱이 번역된 언어’가 아니라 ‘OCR 엔진이 인식해 뱉을 수 있는 글자 집합’이다.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;앱 UI가 11개 언어를 지원한다고 해서 폰트까지 11개 언어의 글리프를 다 들고 있을 필요는 없습니다. OCR 인식기가 추출하는 한국어 완성형 2,350자와 기본 영문/특수기호만 있으면 충분합니다.&lt;/p&gt;
&lt;h1 id=&quot;pretendard-ofl에서-필요한-글자만-굽기-bake-ocr-fontpy&quot;&gt;Pretendard OFL에서 필요한 글자만 굽기 (&lt;code&gt;bake-ocr-font.py&lt;/code&gt;)&lt;/h1&gt;
&lt;p&gt;우리는 오픈소스 폰트인 Pretendard(SIL Open Font License)를 기반으로, PDF 글자층 전용 경량 서브셋 폰트를 생성하는 스크립트를 작성했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# tools/bake-ocr-font.py&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 현대 한글 2,350자 + 영문/숫자 + 기본 기호 + 화폐 기호(₩, $, €, ¥)만 추출&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#7DCFFF&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; fontTools&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;subset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; bake&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;():&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    options &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; fontTools&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;subset&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;Options&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    options&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;layout_features &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    options&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;name_IDs &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    # 유니코드 매핑 ToUnicode 무결성 보존&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#E0AF68&quot;&gt;    ...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과물:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;기존 18MB 폰트 → 380KB의 초경량 OTF 서브셋으로 압축.&lt;/li&gt;
&lt;li&gt;앱 크기에 거의 영향을 주지 않으면서 완벽한 ToUnicode CMap 보장.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 2026-08-23의 교훈을 잊지 않고, 이 스크립트를 임시 폴더가 아니라 프로젝트 저장소의 &lt;code&gt;tools/bake-ocr-font.py&lt;/code&gt;에 영구 자산으로 보관했습니다.&lt;/p&gt;
&lt;h1 id=&quot;jvm-단위-테스트로-tounicode-역추출-검증&quot;&gt;JVM 단위 테스트로 ToUnicode 역추출 검증&lt;/h1&gt;
&lt;p&gt;“빌드가 성공했으니 검색도 잘 되겠지”라는 짐작은 금물입니다.&lt;/p&gt;
&lt;p&gt;우리는 안드로이드 기기를 켜지 않고도 JVM 환경에서 즉시 검증할 수 있도록 &lt;code&gt;pdfbox-android&lt;/code&gt;를 활용한 파서 테스트 코드를 작성했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Test&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; verify_korean_searchable_pdf_to_unicode&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; pdfBytes &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; createSampleSearchablePdf&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;계약서 임대차 보증금 ₩1,850,000 café&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; document &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; PDDocument.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;load&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(pdfBytes)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; stripper &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; PDFTextStripper&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; extractedText &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; stripper.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getText&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(document)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // ToUnicode 매핑이 완벽한지 한 글자씩 대조&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    assertThat&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(extractedText).&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;contains&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;계약서&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    assertThat&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(extractedText).&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;contains&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;보증금&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    assertThat&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(extractedText).&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;contains&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;₩1,850,000&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 테스트가 초록불을 띄운 순간, 비로소 사용자가 안드로이드 기본 PDF 뷰어, Adobe Acrobat, 크롬 브라우저 어디서 열어도 한글이 온전히 검색되는 것을 확신할 수 있었습니다.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;글자층을 얹는 것과 검색이 되는 것은 다릅니다. 핵심은 올바른 &lt;code&gt;/ToUnicode&lt;/code&gt; 매핑입니다.&lt;/li&gt;
&lt;li&gt;필요한 글자만 구워야 합니다. OCR이 뱉는 유니코드 범위를 명확히 규정하면 용량을 98% 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;다시 돌릴 스크립트는 반드시 저장소에 남깁니다.&lt;/li&gt;
&lt;li&gt;눈으로 보지 말고 JVM 테스트로 ToUnicode를 역추출해 검증하세요.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 11 — 있는 척하는 안전장치는 없는 것보다 나쁩니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/fake-safety.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/fake-safety.html</id>
    <published>2026-09-02T12:20:00.000Z</published>
    <updated>2026-09-02T12:20:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="보안"/>
    <category term="비즈니스모델"/>
    <category term="안드로이드"/>
    <category term="CI"/>
    <category term="품질"/>
    <summary type="html">구독 BM과 광고 검사 스크립트를 정비하다가 '있는 척하지만 일은 하지 않던' 안전장치 세 개를 잡았습니다. 평문 'pro' 한 줄로 뚫리던 잠금장치, 아무도 모르는 지문 해시, 반쪽짜리 광고 ID 검사기. 안전하다는 착각이 진짜 위험을 부릅니다.</summary>
    <content type="html">&lt;p&gt;앱에 인앱 결제와 구독 비즈니스 모델(BM)을 세우던 날, 성격이 똑같은 문제를 세 개 잡았습니다.&lt;/p&gt;
&lt;p&gt;셋 다 코드상에는 분명히 존재했고, 얼핏 보면 매우 꼼꼼한 안전장치처럼 보였습니다. 하지만 속을 까보니 아무 일도 하지 않거나 오히려 문을 활짝 열어두고 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;1-암호화-해시-옆에-열려-있던-뒷문&quot;&gt;1. 암호화 해시 옆에 열려 있던 뒷문&lt;/h1&gt;
&lt;p&gt;PRO 구독 해금을 위한 프로모션 코드 검증 로직이 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 겉보기에는 안전한 SHA-256 검증 구조&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; isProCode&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(input: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Boolean&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; inputHash &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; sha256&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(input.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;())&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; input.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;equals&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;pro&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, ignoreCase &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;        ||&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; input.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;equals&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;premium&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, ignoreCase &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;        ||&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; inputHash &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; PROMO_SECRET_HASH&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;옳은 설계: &lt;code&gt;sha256(코드) == PROMO_SECRET_HASH&lt;/code&gt; 검사. 코드를 평문으로 남기지 않고 해시 지문으로만 대조하는 정석적인 구조입니다.&lt;/li&gt;
&lt;li&gt;치명적인 현실: 바로 그 앞에 평문 문자열들이 &lt;code&gt;||&lt;/code&gt; 로 이어져 있었습니다.&lt;/li&gt;
&lt;li&gt;결과: 월 ₩1,900, 연 ₩14,900, 평생 ₩19,900짜리 PRO 기능이 사용자가 ‘pro’ 한 단어만 치면 프리패스로 열렸습니다. 테스트할 때 임시로 넣어둔 조건이 코드에 화석처럼 남아 있었던 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;더 우스운 것은, 그 뒤에 적혀 있던 &lt;code&gt;PROMO_SECRET_HASH&lt;/code&gt;는 과거에 어떤 단어를 해시한 것인지 팀원 누구도 몰랐습니다. (모든 후보 단어를 해시해 봐도 일치하지 않는 죽은 해시였습니다.)&lt;/p&gt;
&lt;h1 id=&quot;2-반만-세고-있던-광고-id-검사-스크립트&quot;&gt;2. 반만 세고 있던 광고 ID 검사 스크립트&lt;/h1&gt;
&lt;p&gt;출시 전 AdMob 테스트 광고 ID가 프로덕션에 나가는 참사를 막기 위해 &lt;code&gt;admob-ids.sh&lt;/code&gt;라는 검사 도구를 돌리고 있었습니다.&lt;/p&gt;
&lt;p&gt;이 스크립트는 소스 코드 내의 광고 ID가 ‘Google 견본 테스트 ID인가’와 ‘비어 있는가’를 둘 다 검사합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# admob-ids.sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#C0CAF5&quot;&gt;CHECK_TARGETS&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;ADMOB_BANNER ADMOB_INTERSTITIAL&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 검사 대상 목록에 배너와 전면 광고 둘만 적혀 있었습니다. 그날 새로 추가된 보상형 광고(Rewarded Ad) 자리는 검사 대상에서 빠져 있어, 견본 ID가 그대로 들어가도 스크립트는 항상 0(성공)을 뱉으며 초록불을 띄우고 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;3-출시-다음-날-태어난-소스가-적힌-changelog&quot;&gt;3. 출시 다음 날 태어난 소스가 적힌 CHANGELOG&lt;/h1&gt;
&lt;p&gt;릴리스 노트를 점검하다가 v1.0.0 절에 당시에는 없었던 고급 기능이 적혀 있는 것을 발견했습니다.&lt;/p&gt;
&lt;p&gt;Git 커밋 로그를 추적해 보니, 해당 소스 코드는 공식 출시 다음 날 밤에 처음 커밋된 것이었습니다.&lt;/p&gt;
&lt;p&gt;문서 작성자가 ‘원래 v1.0.0에 넣으려고 기획했던 내용’을 머릿속 기억에 의존해 릴리스 문서에 미리 적어두었고, 실제 배포된 바이너리와 문서의 내용이 서로 어긋나 있었던 것입니다.&lt;/p&gt;
&lt;h1 id=&quot;가짜-안전장치가-진짜-위험한-이유&quot;&gt;가짜 안전장치가 진짜 위험한 이유&lt;/h1&gt;
&lt;p&gt;안전장치가 아예 없으면 사람은 조심합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;프로모션 코드 입력창을 닫아두거나 수동으로 확인합니다.&lt;/li&gt;
&lt;li&gt;광고 ID를 배포 직전에 눈으로 두 번 세 번 검수합니다.&lt;/li&gt;
&lt;li&gt;CHANGELOG를 쓸 때 커밋 로그를 일일이 대조합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하지만 “여기에 검사 스크립트가 돌아가고 있다”, “SHA-256으로 암호화되어 있다”는 사실을 알고 나면 사람은 안심하고 경계를 풉니다.&lt;/p&gt;
&lt;p&gt;그 안심의 틈새로 진짜 버그와 보안 구멍이 유유히 지나갑니다.&lt;/p&gt;
&lt;h1 id=&quot;청산의-원칙&quot;&gt;청산의 원칙&lt;/h1&gt;
&lt;p&gt;우리는 이 ‘가짜 안전장치’들을 다음과 같이 뜯어고쳤습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;테스트용 백도어 문자열 전면 제거: 로컬 디버그 빌드(&lt;code&gt;BuildConfig.DEBUG&lt;/code&gt;)에서만 동작하는 전용 인스펙터로 분리하고, 릴리스 빌드에서는 조건문 자체가 컴파일 타임에 증발하도록 격리했습니다.&lt;/li&gt;
&lt;li&gt;검사 대상의 동적 열거: 광고 ID 검사 스크립트가 하드코딩된 변수명을 훑는 대신, &lt;code&gt;res/values/strings.xml&lt;/code&gt;에 정의된 &lt;code&gt;admob_*&lt;/code&gt; 프리픽스를 가진 모든 엔트리를 정규식으로 자동 수집하여 검사하게 바꿨습니다.&lt;/li&gt;
&lt;li&gt;릴리스 노트의 태그 기반 생성: CHANGELOG는 사람이 기억으로 쓰지 않고, Git의 릴리스 태그 간 커밋 메시지를 파싱하여 자동으로 뼈대를 세우도록 일치시켰습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;있는 척하는 검사는 없는 검사보다 해롭습니다. 사람의 주의력을 마비시키기 때문입니다.&lt;/li&gt;
&lt;li&gt;테스트 편의를 위해 열어둔 임시 &lt;code&gt;||&lt;/code&gt; 조건문은 언제나 제품의 가장 비싼 기능을 공짜로 엽니다.&lt;/li&gt;
&lt;li&gt;안전장치를 만들었다면, 그 안전장치가 실패하는 테스트 케이스를 먼저 작성해야 합니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 10 — 판올림 하나에 남의 이용약관이 딸려옵니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/version-bump-terms.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/version-bump-terms.html</id>
    <published>2026-09-02T12:10:00.000Z</published>
    <updated>2026-09-02T12:10:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="라이선스"/>
    <category term="의존성"/>
    <category term="GitHub Actions"/>
    <category term="보안"/>
    <category term="오픈소스"/>
    <summary type="html">CI 액션의 버전을 올리다가 멈췄습니다. v6으로 판올림하면 상용 캐시 서비스와 독점 라이선스 약관에 자동 동의하게 되어 있었거든요. 빌드는 초록으로 잘 돌지만, 법적·철학적 경계는 소리 없이 무너집니다.</summary>
    <content type="html">&lt;p&gt;두 앱 저장소의 &lt;code&gt;release.yml&lt;/code&gt;에서 밀려 있던 GitHub Actions 액션 버전들을 점검하다가, &lt;code&gt;gradle/actions/setup-gradle&lt;/code&gt;만 요청받은 v6이 아니라 v5에서 멈췄습니다.&lt;/p&gt;
&lt;p&gt;기술적으로는 아무것도 안 깨집니다. v6으로 올렸어도 빌드는 초록불로 시원하게 돌았을 것이고, 캐시도 빨라졌을지 모릅니다.&lt;/p&gt;
&lt;p&gt;하지만 그 한 줄 아래에는 조용히 바뀐 남의 이용약관과 독점 구성요소가 숨어 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;actionyml에-새로-생긴-한-줄&quot;&gt;&lt;code&gt;action.yml&lt;/code&gt;에 새로 생긴 한 줄&lt;/h1&gt;
&lt;p&gt;v6의 &lt;code&gt;action.yml&lt;/code&gt;을 열어보았을 때 &lt;code&gt;cache-provider&lt;/code&gt;라는 새로운 입력값이 보였습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;inputs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;  cache-provider&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;    description&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; '&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;The cache provider to use&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;    default&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; '&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;enhanced&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; # commercial caching service&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기본값이 &lt;code&gt;'enhanced'&lt;/code&gt;로 설정되어 있었고, 주석에는 아주 작게 ‘commercial caching service’ 라고 적혀 있었습니다.&lt;/p&gt;
&lt;p&gt;출처를 따라가 보니, v6부터는 성능 개선이라는 이름 아래 캐시 기능을 &lt;code&gt;gradle-actions-caching&lt;/code&gt;이라는 비공개 상용 바이너리로 분리해 두고 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;조용히-바뀐-license-파일&quot;&gt;조용히 바뀐 LICENSE 파일&lt;/h1&gt;
&lt;p&gt;더 결정적인 것은 LICENSE 파일의 변화였습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;v5까지: 순수한 ‘The MIT License’ 단독 라이선스.&lt;/li&gt;
&lt;li&gt;v6부터: ‘벤더 포함 독점 구성요소(Proprietary components)가 포함되어 있다’는 안내 문구 추가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;릴리스 노트에는 이렇게 명시되어 있었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“v6으로 액션을 실행하면 Gradle의 상용 서비스 이용약관에 동의하는 것으로 간주됩니다.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 상용 캐시 요소는 옵션을 끄지 않는 한 기본으로 로드됩니다. 그리고 대부분의 팀은 빌드 속도를 위해 캐시를 켜서 씁니다.&lt;/p&gt;
&lt;h1 id=&quot;편리함-뒤에-숨은-침묵의-동의&quot;&gt;편리함 뒤에 숨은 ‘침묵의 동의’&lt;/h1&gt;
&lt;p&gt;오픈소스 생태계에서 &lt;code&gt;v5 -&gt; v6&lt;/code&gt; 같은 메이저 버전 판올림은 보통 API 브레이킹 체인지를 점검하는 자리입니다.&lt;/p&gt;
&lt;p&gt;누구도 “이 버전을 쓰면 상용 클라우드 서비스 약관에 서명하게 된다”는 사실을 예상하지 못합니다.&lt;/p&gt;
&lt;p&gt;만약 Dependabot이나 자동 머지 봇이 열어준 PR을 보고 &lt;code&gt;npm test&lt;/code&gt;나 CI 빌드가 초록불이라는 이유만으로 머지했다면, 우리는 우리도 모르는 사이에 회사의 빌드 머신에 서드파티 상용 에이전트를 상주시킨 셈이 됩니다.&lt;/p&gt;
&lt;h1 id=&quot;기술적-성공과-옳은-결정은-다릅니다&quot;&gt;기술적 성공과 옳은 결정은 다릅니다&lt;/h1&gt;
&lt;p&gt;이것이 바로 조용히 실패하는 또 하나의 전형적인 모습입니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;컴파일러는 약관을 읽지 않습니다: 바이너리가 제대로 로드되어 0을 뱉으면 정상으로 판단합니다.&lt;/li&gt;
&lt;li&gt;CI 로그는 깨끗합니다: 오히려 빌드 시간이 10초 단축되었다고 자랑합니다.&lt;/li&gt;
&lt;li&gt;위험은 법적·구조적 층위에 쌓입니다: 저장소의 라이선스 투명성이 훼손되고, 언제 과금 정책이 바뀔지 모르는 비공개 바이너리에 종속됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;우리는 즉시 v6 판올림을 거부하고 v5.x의 마지막 안정 버전으로 고정(Pinning)했습니다. 10초의 빌드 속도보다 저장소의 무결성과 독립성이 훨씬 값지기 때문입니다.&lt;/p&gt;
&lt;h1 id=&quot;의존성을-대하는-세-가지-원칙&quot;&gt;의존성을 대하는 세 가지 원칙&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;메이저 판올림 시 라이선스 diff를 반드시 확인합니다: 코드가 바뀌지 않았더라도 라이선스 텍스트가 바뀌면 그것은 다른 제품입니다.&lt;/li&gt;
&lt;li&gt;기본값(default)에 상용 네트워크 호출이 있는지 봅니다: 외부 벤더 서버로 통신하거나 토큰을 요구하는지 액션 메타데이터를 검수합니다.&lt;/li&gt;
&lt;li&gt;빠르다고 다 좋은 것이 아닙니다: 빌드가 조금 느리더라도 무엇이 도는지 명확히 통제할 수 있는 빌드 환경을 유지합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;판올림은 기능과 성능만 가져오지 않습니다. 때로는 새로운 제약과 남의 약관을 싣고 옵니다.&lt;/li&gt;
&lt;li&gt;검사가 초록이고 속도가 빨라졌다고 해서 올바른 선택인 것은 아닙니다.&lt;/li&gt;
&lt;li&gt;도구가 내미는 편리함에 서명하기 전에, 그 도구가 무엇을 대가로 요구하는지 읽어야 합니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 9 — 틀린 저장소를 가리키는 수첩은 죽지 않고 성공합니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/notebook-wrong-repo.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/notebook-wrong-repo.html</id>
    <published>2026-09-02T12:00:00.000Z</published>
    <updated>2026-09-02T12:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="git"/>
    <category term="배포"/>
    <category term="자동화"/>
    <category term="문서"/>
    <category term="CI"/>
    <summary type="html">배포 수첩에 적힌 저장소 주소가 다른 제품의 것이었습니다. 명령은 에러 없이 빌드·푸시·배포까지 성공했고, 그 결과 다른 제품 화면이 사라질 뻔했어요. 틀린 명령은 죽어서 잡히지만, 틀린 주소는 성공해서 사고를 만듭니다.</summary>
    <content type="html">&lt;p&gt;하루에 배포 수첩(&lt;code&gt;releaser.md&lt;/code&gt;)의 오류를 둘 잡았는데, 둘의 성격이 정반대였습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;낮: 스크립트 절이 &lt;code&gt;npm run lint &amp;#x26;&amp;#x26; npm run build&lt;/code&gt;를 부르는데 그 저장소에는 둘 다 없었습니다(있는 것은 &lt;code&gt;typecheck&lt;/code&gt;·&lt;code&gt;test&lt;/code&gt;·&lt;code&gt;build:web&lt;/code&gt;). 그대로 따르면 첫 줄에서 에러를 내며 죽습니다.&lt;/li&gt;
&lt;li&gt;밤: portfolio 절이 원격 저장소를 &lt;code&gt;twinkle-ai-labs.github.io&lt;/code&gt;라고 적어 뒀는데 그건 소개 홈(web/home)의 저장소였습니다. portfolio의 진짜 원격은 &lt;code&gt;twinkle-ai-labs/portfolio&lt;/code&gt;였어요. 그대로 따르면 빌드도 되고 푸시도 되고 배포도 됩니다 — 그리고 소개 화면이 덮어씌워져 사라집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;앞의 것은 잡는 데 3초가 걸렸습니다. 뒤의 것은 마침 그날 홈을 두 번 배포했던 사람이 저장소 이름을 눈에 담고 있었기 때문에 잡혔어요. 그 우연이 없었으면 포트폴리오를 내는 날까지 초록으로 서 있었을 것입니다.&lt;/p&gt;
&lt;h1 id=&quot;틀린-명령은-스스로를-고발합니다&quot;&gt;틀린 명령은 스스로를 고발합니다&lt;/h1&gt;
&lt;p&gt;낮의 오류는 착한 오류였습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; lint&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; error&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; Missing&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; script:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;lint&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; error&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; error&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; To&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; see&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; scripts,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; run:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; error&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;   npm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; run&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;도구가 즉시 멈추고 빨간 글씨로 항의합니다. 실행한 사람은 1초 만에 ‘아, 여기 스크립트 이름은 &lt;code&gt;typecheck&lt;/code&gt;구나’ 하고 수첩을 고칩니다.&lt;/p&gt;
&lt;p&gt;컴퓨터가 멈춰 주는 오류는 가장 값싼 오류예요.&lt;/p&gt;
&lt;h1 id=&quot;틀린-주소는-끝까지-달립니다&quot;&gt;틀린 주소는 끝까지 달립니다&lt;/h1&gt;
&lt;p&gt;하지만 밤의 오류는 전혀 다른 괴물이었습니다.&lt;/p&gt;
&lt;p&gt;포트폴리오 빌드 산출물을 &lt;code&gt;twinkle-ai-labs.github.io&lt;/code&gt;로 푸시하라는 지시는 다음과 같은 과정을 거칩니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;포트폴리오 프로젝트를 빌드한다 (&lt;code&gt;dist/&lt;/code&gt; 폴더 생성) — 성공 (exit 0)&lt;/li&gt;
&lt;li&gt;지정된 원격(&lt;code&gt;twinkle-ai-labs.github.io&lt;/code&gt;)의 &lt;code&gt;gh-pages&lt;/code&gt; 브랜치로 푸시한다 — 성공 (exit 0)&lt;/li&gt;
&lt;li&gt;GitHub Pages가 새 커밋을 받아 정적 사이트를 배포한다 — 성공 (exit 0)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;모든 파이프라인이 초록불입니다. GitHub Actions도 성공, 터미널도 성공, 배포 알림 봇도 성공을 외칩니다.&lt;/p&gt;
&lt;p&gt;그리고 브라우저로 &lt;a href=&quot;https://twinklelabs.kr&quot;&gt;twinklelabs.kr&lt;/a&gt;에 접속하면, 회사와 제품들을 소개하던 홈 화면 대신 3D 포트폴리오가 떠 있습니다.&lt;/p&gt;
&lt;h1 id=&quot;도구는-내가-누구를-덮어쓰는지-묻지-않습니다&quot;&gt;도구는 “내가 누구를 덮어쓰는지” 묻지 않습니다&lt;/h1&gt;
&lt;p&gt;Git과 CI 도구에게 원격 주소는 그저 하나의 목적지일 뿐입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“이 저장소에 이미 있는 파일들과 전혀 다른 트리인데 괜찮습니까?”라고 묻지 않습니다.&lt;/li&gt;
&lt;li&gt;“너는 포트폴리오인데 왜 홈 화면 도메인에 덮어쓰니?”라고 의심하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;인증 토큰(PAT)이 두 저장소에 모두 쓰기 권한을 갖고 있는 순간, 도구는 주인의 명령대로 완벽하게 배포를 완수합니다.&lt;/p&gt;
&lt;p&gt;도구가 똑똑해질수록, 엉뚱한 목적지를 향해 더 빠르고 깔끔하게 파멸로 달려갑니다.&lt;/p&gt;
&lt;h1 id=&quot;수첩은-코드보다-검증받기-어렵습니다&quot;&gt;수첩은 코드보다 검증받기 어렵습니다&lt;/h1&gt;
&lt;p&gt;코드는 매일 빌드되고, 타입스크립트 컴파일러가 매 줄을 감시하며, 유닛 테스트가 돌아갑니다.&lt;/p&gt;
&lt;p&gt;하지만 배포 가이드, 운영 수첩, 릴리스 문서는 마크다운으로 적힌 글자일 뿐입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;마크다운 린터는 URL이 유효한 영문인지만 보지, 그것이 올바른 제품의 저장소인지 모릅니다.&lt;/li&gt;
&lt;li&gt;배포 당일 전까지는 아무도 그 줄을 실행하지 않습니다.&lt;/li&gt;
&lt;li&gt;한 번 잘못 적힌 수첩은 다음 릴리스 때까지 어두운 방에서 조용히 대기합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id=&quot;어떻게-막을-것인가&quot;&gt;어떻게 막을 것인가&lt;/h1&gt;
&lt;p&gt;이 사고를 겪고 수첩과 스크립트의 관계를 다시 세웠습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;사람이 주소를 치지 않게 합니다: 배포 스크립트는 원격 저장소 URL을 수동으로 입력받지 않고, 현재 프로젝트의 &lt;code&gt;package.json&lt;/code&gt;이나 &lt;code&gt;git remote get-url origin&lt;/code&gt;에서 직접 추출하도록 바꿉니다.&lt;/li&gt;
&lt;li&gt;배포 대상 도메인과 소스 일치 검증: 배포 액션 상단에 ‘현재 프로젝트 이름’과 ‘대상 Pages의 CNAME’이 약속된 조합인지 확인하는 3줄짜리 가드(Guard) 스크립트를 세웁니다.&lt;/li&gt;
&lt;li&gt;수첩을 코드의 자식으로 둡니다: 사람이 문서를 보고 명령을 복사-붙여넣기 하는 구조를 버리고, &lt;code&gt;npm run release&lt;/code&gt;라는 하나의 엔트리포인트로 묶어 검증된 경로만 타게 만듭니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;다음-작업을-위한-메모&quot;&gt;다음 작업을 위한 메모&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;죽는 오류는 안전합니다. 실행을 멈추고 고쳐질 기회를 주니까요.&lt;/li&gt;
&lt;li&gt;성공하는 오류는 위험합니다. 모든 단계가 초록불로 통과하며 뒤에서 원래의 데이터를 지웁니다.&lt;/li&gt;
&lt;li&gt;배포 문서와 수첩은 코드와 같은 무게로 검증되어야 합니다. 눈으로 읽는 검토는 우연에 기댈 뿐입니다.&lt;/li&gt;
&lt;li&gt;도구에게 바른 길을 알려주지 않으면, 도구는 틀린 길을 가장 완벽하게 완주합니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 10 — 스퀴클은 원이 아니에요 - 앱 아이콘을 세 번 틀리고 굽는 법</title>
    <link href="https://blog.twinklelabs.kr/android/app-icon-baking.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/app-icon-baking.html</id>
    <published>2026-09-02T11:30:00.000Z</published>
    <updated>2026-09-02T11:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="앱 아이콘"/>
    <category term="적응형 아이콘"/>
    <category term="런처"/>
    <category term="디자인"/>
    <category term="Android"/>
    <summary type="html">둥근 타일 원본을 적응형 아이콘에 넣는 단순한 일에서 세 번 틀렸습니다. 모서리가 두 겹이 되고, 검은 초승달이 남고, 아이콘이 제 칸의 절반도 못 채웠어요. 틀릴 때마다 배운 건 하나였습니다 — 짐작하지 말고 그림에게 물어라.</summary>
    <content type="html">&lt;p&gt;앱 아이콘 원본이 하나 도착합니다. 보라 그라데이션 바탕에 그림이 올라간,
모서리가 둥근 타일이 검정 위에 얹힌 PNG요. 이걸 안드로이드 앱에
넣으면 되는 일입니다. 단순해 보이죠.&lt;/p&gt;
&lt;p&gt;여기서 세 번 틀렸습니다. 그 기록이에요.&lt;/p&gt;
&lt;h2 id=&quot;배경-지식--적응형-아이콘은-잘리는-것이-일입니다&quot;&gt;배경 지식 — 적응형 아이콘은 ‘잘리는 것이 일’입니다&lt;/h2&gt;
&lt;p&gt;안드로이드 8부터 런처 아이콘은 적응형 아이콘(adaptive icon)입니다.
앱은 108dp짜리 앞면과 뒷면 두 겹을 내고, 모양은 런처가 정해요 —
원, 스퀴클, 둥근 네모, 기기마다 다른 마스크로 오려 냅니다.&lt;/p&gt;
&lt;p&gt;중요한 숫자: 108dp 중 마스크가 남기는 건 가운데 72dp뿐입니다.
바깥 테두리는 패럴랙스 같은 효과를 위한 여유분이라, 언제든 잘려 나갈 수
있는 영역이에요. 즉 꽉 채워 그리면 사방이 잘립니다. 잘리는 게 버그가
아니라 사양입니다.&lt;/p&gt;
&lt;p&gt;그런데 받은 원본은 이미 모서리가 둥글게 구워진 타일입니다.
그대로 넣으면 어떻게 될까요.&lt;/p&gt;
&lt;h2 id=&quot;틀리기-1--잘라내면-그림이-커집니다&quot;&gt;틀리기 1 — 잘라내면 그림이 커집니다&lt;/h2&gt;
&lt;p&gt;원본의 네 구석은 까맣습니다(둥근 타일 밖이니까요). 그대로 넣으면
런처가 제 모양대로 다시 둥글려서 모서리가 두 겹이 되고,
사이에 검은 초승달이 남아요.&lt;/p&gt;
&lt;p&gt;첫 시도: 사방을 타일 반지름만큼 잘라내자. 검은 구석이 사라지겠지.&lt;/p&gt;
&lt;p&gt;사라지긴 합니다. 대신 다른 게 생겨요 — 그림이 커집니다.
사방에서 6.5%를 잘라내면 남은 그림이 판을 채우도록 늘어나니까
15% 확대되는 셈이거든요. 안전 구역에 맞춰 그려진 그림이
그 밖으로 밀려 나갑니다. 잘림을 피하려다 잘림을 만든 거죠.&lt;/p&gt;
&lt;h2 id=&quot;틀리기-2--스퀴클은-원이-아닙니다&quot;&gt;틀리기 2 — 스퀴클은 원이 아닙니다&lt;/h2&gt;
&lt;p&gt;두 번째 시도: 원본을 둥근 사각형으로 오려 내고, 그 밑에 배경
그라데이션을 깔자. 오려낸 곡선 밖은 밑색이 받쳐 주겠지.&lt;/p&gt;
&lt;p&gt;이게 안 되는 이유가 이 글의 제목입니다.&lt;/p&gt;
&lt;p&gt;원본의 구워진 모서리는 원호가 아니라 스퀴클(squircle) — 원보다
배가 부른 곡선입니다. 실제로 재 봤어요. 타일 맨 윗줄(y=0)의 검은 구간이
80px일 때, 원이라면 y=40에서 검은 구간이 11px로 줄어야 하는데
실제로는 17px이었습니다. 원으로 오려 내면 스퀴클과 원 사이의
초승달이 그대로 남아요.&lt;/p&gt;
&lt;p&gt;덤으로 하나 더 — 밑에 깐 그라데이션도 배신합니다. 원본 배경이
순수한 세로 그라데이션이 아니라 구석이 더 어두웠거든요
(위 한가운데 &lt;code&gt;#AE7CEA&lt;/code&gt;, 왼쪽 위 구석 &lt;code&gt;#9968E7&lt;/code&gt;). 밑색이 구석에서
더 밝아서, 타일 테두리가 한 겹 윤곽선으로 떠올랐습니다.&lt;/p&gt;
&lt;p&gt;합성할 색을 짐작하면 안 됩니다. 그라데이션이겠거니, 원호겠거니 —
둘 다 재 보면 아니었어요.&lt;/p&gt;
&lt;h2 id=&quot;통한-방법--가장자리를-늘려-되메웁니다&quot;&gt;통한 방법 — 가장자리를 늘려 되메웁니다&lt;/h2&gt;
&lt;p&gt;정답은 오려 내는 것도, 밑색을 까는 것도 아니었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;사방을 반지름+2px만큼 걷어내고, 걷어낸 만큼을 테두리 픽셀의 복제로
되메웁니다&lt;/strong&gt;(이미지 도구의 &lt;code&gt;extendWith: &quot;copy&quot;&lt;/code&gt;). 그림은 제 자리
제 크기에 남고, 채워 넣은 색은 타일의 가장자리 그 자체라
이음매가 원리적으로 생길 수 없어요. 스퀴클의 곡률도, 구석의 어두움도
알 필요가 없습니다 — 짐작할 색이 없으니까요.&lt;/p&gt;
&lt;h2 id=&quot;틀리기-3--안-잘리는-것과-잘-보이는-것은-다릅니다&quot;&gt;틀리기 3 — 안 잘리는 것과 잘 보이는 것은 다릅니다&lt;/h2&gt;
&lt;p&gt;이제 크기입니다. 108dp 중 72dp만 보인다니 겁이 나서,
타일을 72dp로 줄여 앉혔습니다. 안 잘리긴 해요. 그런데 —&lt;/p&gt;
&lt;p&gt;그림이 타일의 53%였으니 108 판에서 실제 그림은 38dp.
런처에 서면 제 칸의 절반도 못 채우는 작고 흐릿한 덩어리가 됐습니다.
옆 아이콘들 사이에서 혼자 왜소해요. &lt;strong&gt;‘안 잘린다’는 조건이지
‘잘 보인다’는 답이 아닙니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그럼 얼마가 맞을까요. 여기서 관점이 하나 바뀝니다 — 잘리는 경계는
사각형이 아니라 원입니다. 어떤 마스크가 와도 지름 66dp 원 안은
남아요(키라인 원). 그러니 ‘얼마로 줄일까’를 짐작하지 말고
그림에게 묻습니다: 밝은 화소의 최대 반지름을 재어, 그 지름이
66dp가 되는 타일 크기를 역산하는 거예요.&lt;/p&gt;
&lt;p&gt;…라고 하고 66으로 맞췄더니 또 반쪽짜리였습니다.
그림이 마스크 가장자리에 그대로 닿아서 여백이 한 톨도 없었고,
런처에서 혼자 부푼 것처럼 보였어요. &lt;strong&gt;66은 ‘안 잘리는 한계’지
‘보기 좋은 목표’가 아닙니다.&lt;/strong&gt; 한계와 목표는 다른 값이에요.&lt;/p&gt;
&lt;p&gt;최종은 54dp — 판(108)의 절반입니다. 보이는 원(72dp) 안에서
사방 9dp가 숨 쉬고, 실패했던 38dp보다는 확실히 큽니다.&lt;/p&gt;
&lt;h2 id=&quot;그리고-이걸-두-번-손으로-하지-않습니다&quot;&gt;그리고, 이걸 두 번 손으로 하지 않습니다&lt;/h2&gt;
&lt;p&gt;이 삽질의 결론이 ‘올바른 값들’이었다면 반만 배운 겁니다.
진짜 결론은 이 일을 도구로 만들어 저장소에 두는 것이었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;node brand/app-icon.mjs&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;원본이 새로 오면 이거 하나를 돌립니다. 반지름은 박아 두지 않고
맨 윗줄의 검은 구간을 세어 원본마다 다시 잽니다. 뒷면 색도
브랜드색을 짐작해 넣지 않고 로고의 끝 색을 원본에서 재어 씁니다 —
앞면이 불투명하면 뒷면이 보이는 건 런처가 앞면을 움직일 때뿐이고,
그때 드러나는 색이 로고와 다를 이유가 없거든요.&lt;/p&gt;
&lt;p&gt;사실 이 일을 처음 한 날은 스크립트를 &lt;strong&gt;스크래치패드에서 돌리고
잃어버렸습니다.&lt;/strong&gt; 리소스 파일의 주석이 이미 없는 파일을 가리키고
있는 걸 나중에 발견했어요. 다음에도 돌릴 것은 저장소에 삽니다.&lt;/p&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;적응형 아이콘은 잘리는 게 사양입니다. 108 중 보이는 건 72,
어떤 마스크에도 남는 건 지름 66dp 원 — 이 숫자들을 모르면
어느 방향으로든 틀립니다.&lt;/li&gt;
&lt;li&gt;스퀴클은 원이 아니고, 그라데이션은 균일하지 않습니다.
합성할 값을 짐작하지 말고 재세요 — 제일 좋은 건 잴 필요가 없는 방법
(가장자리 복제)입니다.&lt;/li&gt;
&lt;li&gt;한계와 목표는 다른 값입니다. ‘안 잘린다’(66)는 조건이고,
‘잘 보인다’(54)는 그 조건 안에서 따로 골라야 하는 답이에요.&lt;/li&gt;
&lt;li&gt;한 번 한 삽질은 도구가 되어 저장소에 남아야 합니다.
스크래치에서 돌린 스크립트는 세션과 함께 사라집니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 8 — 커밋도 옳고 출시도 옳았는데, 갈라진 자리만 틀렸어요</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/branch-from-main.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/branch-from-main.html</id>
    <published>2026-09-02T11:00:00.000Z</published>
    <updated>2026-09-02T11:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="git"/>
    <category term="브랜치"/>
    <category term="출시"/>
    <category term="develop"/>
    <category term="main"/>
    <summary type="html">v1.0.8은 잘 나갔습니다. 번호도, CHANGELOG도, 출시 노트도 다 있었어요. 그래서 아무도 몰랐습니다 — develop이 그 판을 통째로 모르고 있다는 걸요. 브랜치가 main에서 갈라져 main으로 돌아갔기 때문입니다.</summary>
    <content type="html">&lt;p&gt;저희 저장소의 브랜치 규칙은 단순합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;코드는 기능 브랜치에서 낳아 &lt;code&gt;develop&lt;/code&gt;으로 합친다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;은 출시된 것만 담는다 — &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt;은 출시할 때 한 번&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;main&lt;/code&gt;에 밀면 CI가 곧장 Play 내부 테스트 트랙에 번들을 올리기 때문에,
&lt;code&gt;main&lt;/code&gt;을 작업 공간처럼 쓰면 고치는 중인 코드가 스토어로 나갑니다.
이 규칙은 잘 지켜지고 있었어요. 적어도 그렇게 보였습니다.&lt;/p&gt;
&lt;h2 id=&quot;사고는-아무-문제-없이-일어났습니다&quot;&gt;사고는 아무 문제 없이 일어났습니다&lt;/h2&gt;
&lt;p&gt;8월 26일, 이력 상세 화면을 다듬는 브랜치가 하나 만들어졌습니다.
작업은 깔끔했고, 리뷰도 통과했고, 합쳐졌고, v1.0.8로 출시됐습니다.
버전 번호도 옳았고, CHANGELOG도 적혔고, 출시 노트도 있었어요.&lt;/p&gt;
&lt;p&gt;그래서 아무도 몰랐습니다.&lt;/p&gt;
&lt;p&gt;그 브랜치는 &lt;code&gt;develop&lt;/code&gt;이 아니라 &lt;code&gt;main&lt;/code&gt;의 커밋에서 갈라졌고,
&lt;code&gt;main&lt;/code&gt;으로 곧장 합쳐졌거든요. 커밋 하나하나는 다 옳았습니다.
틀린 것은 갈라진 자리 하나뿐이었어요. 그리고 그 자리는
이력 그래프를 그려 보기 전에는 눈에 보이지 않습니다.&lt;/p&gt;
&lt;p&gt;이게 무슨 문제냐면 — &lt;code&gt;main&lt;/code&gt;에서 갈라져 &lt;code&gt;main&lt;/code&gt;으로 돌아간 일은,
&lt;code&gt;develop&lt;/code&gt;이 영영 모릅니다. v1.0.8이 바꾼 동작, 되돌린 결정, 고친 버그 —
전부 &lt;code&gt;develop&lt;/code&gt;에는 없는 채로, 두 브랜치가 각자 멀쩡히 자랍니다.&lt;/p&gt;
&lt;h2 id=&quot;하루-뒤에-우연히&quot;&gt;하루 뒤에, 우연히&lt;/h2&gt;
&lt;p&gt;다음 날 &lt;code&gt;develop&lt;/code&gt;에서 다른 작업을 하다가 이상한 걸 봤습니다.
이력 항목을 누르면 상세가 열리는 옛 동작이 그대로 있는 거예요.
어제 v1.0.8이 바꾼 자리인데요.&lt;/p&gt;
&lt;p&gt;따라가 보니 &lt;code&gt;develop&lt;/code&gt;은 v1.0.8을 통째로 모르고 있었습니다.&lt;/p&gt;
&lt;p&gt;발견하지 못했다면 어떻게 됐을까요. 다음 출시 때 &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt;이
나가는 순간, v1.0.8이 되돌린 것과 고친 것이 조용히 되살아납니다.
사용자 입장에서는 ‘고쳐졌던 버그가 다음 판에서 돌아온’ 거예요.
그리고 그 되살아남은 diff에서조차 자연스러워 보입니다 — &lt;code&gt;develop&lt;/code&gt;
쪽에는 그 코드가 원래부터 그렇게 있었으니까요.&lt;/p&gt;
&lt;p&gt;처치는 머지 하나였습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Merge origin/main into develop — v1.0.8 이 develop 을 거치지 않았다&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main&lt;/code&gt;을 &lt;code&gt;develop&lt;/code&gt;으로 되돌려 합쳐서 두 브랜치가 다시 같은 역사를
공유하게 했어요. 빠져 있던 &lt;code&gt;v1.0.8&lt;/code&gt; 태그도 다음 출시 때 보충했습니다.&lt;/p&gt;
&lt;h2 id=&quot;main에서-일하지-않는다는-두-문장이었습니다&quot;&gt;‘main에서 일하지 않는다’는 두 문장이었습니다&lt;/h2&gt;
&lt;p&gt;이 사고에서 배운 건, 규칙이 반쪽만 적혀 있었다는 겁니다.&lt;/p&gt;
&lt;p&gt;‘main에서 일하지 않는다’를 저희는 ‘main에서 커밋하지 않는다’로
읽고 있었어요. 그 반은 잘 지켜졌습니다. 그런데 이 규칙에는 반이 더 있었어요 —
‘main에서 브랜치를 따지 않는다’.&lt;/p&gt;
&lt;p&gt;브랜치는 갈라진 곳으로 돌아가려는 관성이 있습니다. &lt;code&gt;main&lt;/code&gt;에서 딴 브랜치는
자연스럽게 &lt;code&gt;main&lt;/code&gt;으로 머지되고, 그 순간 &lt;code&gt;develop&lt;/code&gt;은 소외됩니다.
커밋 금지만으로는 이 경로가 막히지 않아요.&lt;/p&gt;
&lt;p&gt;그래서 규칙을 두 가지로 보강했습니다.&lt;/p&gt;
&lt;p&gt;하나, 브랜치를 따기 전에 한 번 봅니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; rev-parse&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#E0AF68&quot;&gt; --abbrev-ref&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; HEAD&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;develop&lt;/code&gt;이 아니면 따지 않습니다. 5초짜리 확인이에요 — 폴더를 옮기는
일은 5초고, 옮긴 걸 아무도 못 알아채는 일은 몇 달이라는 걸 이미
겪어봤거든요. 갈라진 자리도 똑같습니다.&lt;/p&gt;
&lt;p&gt;둘, 사람이 세지 않습니다. 출시가 끝난 뒤 &lt;code&gt;main&lt;/code&gt;에는 &lt;code&gt;develop&lt;/code&gt;에 없는
커밋이 하나도 없어야 한다 — 이걸 커밋 훅이 부르는 검사 스크립트가
저장소마다 셉니다. 눈으로 안 잡히는 사고는 기계가 세야 해요.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;모든 단계가 옳아도 전체가 틀릴 수 있습니다. 커밋도, 리뷰도, 출시도
옳았어요. 틀린 건 시작점 하나였고, 그건 어느 단계의 체크리스트에도
없었습니다.&lt;/li&gt;
&lt;li&gt;성공한 출시가 사고를 가립니다. v1.0.8이 잘 나갔기 때문에 아무도
의심하지 않았어요. 실패는 시끄럽지만 성공은 조용합니다.&lt;/li&gt;
&lt;li&gt;되살아나는 코드는 diff에서 자연스러워 보입니다. 잃어버린 판을 모르는
브랜치에게 옛 코드는 ‘원래 있던 것’이에요. 리뷰로는 못 잡습니다.&lt;/li&gt;
&lt;li&gt;규칙은 금지하려는 경로를 전부 적어야 합니다. ‘거기서 일하지 않는다’는
커밋 금지 더하기 분기 금지였어요. 반만 적힌 규칙은 반만 지켜집니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 7 — 검사가 초록인 것과 옳은 것은 다릅니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/sample-ad-unit.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/sample-ad-unit.html</id>
    <published>2026-09-02T10:30:00.000Z</published>
    <updated>2026-09-02T10:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="AdMob"/>
    <category term="광고"/>
    <category term="검사"/>
    <category term="자동화"/>
    <category term="출시"/>
    <summary type="html">출시 직전의 빌드에 구글 견본 광고 ID가 실려 있었습니다. 개발 중엔 광고가 멀쩡히 떠서 아무도 몰랐어요. 잡아 준 건 검사 스크립트였는데 — 그 검사도 절반만 세고 있었습니다.</summary>
    <content type="html">&lt;p&gt;Pocket PDF에 구독을 붙이던 날, 보상형 광고 자리가 하나 새로 생겼습니다.
그리고 출시 빌드 직전에 발견했어요 — 그 자리가 이고 있던 광고 단위 ID가
구글이 문서에 적어 둔 견본 ID였다는 걸요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ca-app-pub-3940256099942544/...   ← 구글의 테스트용 견본&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;견본 ID로 광고를 부르면 개발 중에는 멀쩡히 뜹니다. 그러라고 있는 ID니까요.
화면도 정상, 로그도 정상, 보상 지급도 정상. 그래서 조용합니다.&lt;/p&gt;
&lt;p&gt;이대로 출시되면 어떻게 되냐면 — 수익이 0인 걸로 끝나지 않습니다.
실제 사용자에게 테스트 광고를 내보내는 건 AdMob 정책 위반이에요.
계정 경고나 정지로 이어질 수 있는 사고입니다. 광고가 안 뜨는 버그보다
광고가 잘 뜨는 이 상태가 훨씬 위험한 거죠.&lt;/p&gt;
&lt;h2 id=&quot;잡은-건-검사였습니다--그런데&quot;&gt;잡은 건 검사였습니다 — 그런데&lt;/h2&gt;
&lt;p&gt;이걸 잡아 준 건 사람 눈이 아니라 &lt;code&gt;admob-ids.sh&lt;/code&gt;라는 검사 스크립트였습니다.
저장소의 광고 ID들이 견본이 아닌지 세는 검사예요.&lt;/p&gt;
&lt;p&gt;그런데 이 사건에서 제일 남는 건 그다음입니다.
그 검사도 절반만 세고 있었어요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;admob-ids.sh&lt;/code&gt;는 두 가지를 봅니다 — ‘견본인가’와 ‘비었는가’.
견본 검사에는 보상형 자리가 걸렸는데, &lt;strong&gt;빈 값 검사의 목록에는 보상형이
없었습니다.&lt;/strong&gt; 광고 자리를 새로 만들면서 검사 목록에는 더하지 않은 거예요.&lt;/p&gt;
&lt;p&gt;검사가 초록이었다는 사실이 말해 주는 건 ‘검사한 것들이 통과했다’까지입니다.
‘검사 안 한 것이 없다’는 아무도 보증하지 않아요. 그리고 안 세고 있다는
사실 자체가 조용합니다 — 목록에 없는 자리는 실패조차 못 하니까요.&lt;/p&gt;
&lt;p&gt;같은 병을 며칠 뒤에 한 번 더 봤습니다. 언어 파일의 열쇠 개수를 세는
&lt;code&gt;strings-parity.sh&lt;/code&gt;는 내내 초록이었는데, 스페인어 자리에 영어 문장이
들어 있었어요. 열쇠만 세는 검사에게 값의 언어는 보이지 않습니다.
검사는 겨눈 것만 셉니다.&lt;/p&gt;
&lt;h2 id=&quot;견본과-빈-값은-다릅니다&quot;&gt;견본과 빈 값은 다릅니다&lt;/h2&gt;
&lt;p&gt;발견한 뒤의 처치도 적어 둘 만합니다. 실제 광고 단위는 아직 AdMob 콘솔에서
발급 전이었어요. 그럼 그 자리에 뭘 넣어 둘까요?&lt;/p&gt;
&lt;p&gt;견본을 도로 넣는 건 답이 아닙니다. 방금 그게 사고였으니까요.
빈 값을 넣습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;견본이 나가면: 정책 위반 사고&lt;/li&gt;
&lt;li&gt;빈 값이 나가면: 광고가 안 뜰 뿐&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;검사는 둘 다 빨갛게 잡습니다. 하지만 빨강이 하는 말이 달라져요.
‘견본을 들고 있다’(나가면 사고)에서 ‘아직 발급받지 못했다’(나가기 전에
채울 것)로요. 같은 빨강이라도 하나는 지뢰고 하나는 할 일 목록입니다.&lt;/p&gt;
&lt;p&gt;준비 상태도 자리마다 따로 셌습니다. &lt;code&gt;isRewardedReady&lt;/code&gt;를 배너의 &lt;code&gt;isReady&lt;/code&gt;와
분리했어요 — 하나로 뭉치면 보상형이 비었다는 이유로, 실제 값을 멀쩡히 든
배너까지 함께 죽습니다.&lt;/p&gt;
&lt;h2 id=&quot;같은-날-같은-병-하나-더&quot;&gt;같은 날, 같은 병 하나 더&lt;/h2&gt;
&lt;p&gt;같은 커밋에서 하나가 더 나왔는데, 결이 같아서 같이 적습니다.&lt;/p&gt;
&lt;p&gt;PRO 기능을 여는 프로모션 코드에는 sha256 지문 검사가 붙어 있었습니다.
읽으면 단단해 보여요. 그런데 그 옆에 개발 중에 쓰던 평문 비교 넷이
&lt;code&gt;||&lt;/code&gt;로 나란히 서 있었습니다. 그중 하나가 &lt;code&gt;&quot;pro&quot;&lt;/code&gt;였어요.&lt;/p&gt;
&lt;p&gt;‘pro’ 한 단어로 PRO가 열렸습니다.&lt;/p&gt;
&lt;p&gt;방어는 가장 약한 가지의 높이만큼만 섭니다 — &lt;code&gt;||&lt;/code&gt;로 이어진 조건에서는
특히요. 지문을 두고 그 옆에 평문을 적으면 지문이 하는 일이 없습니다.
자물쇠를 달아 놓고 열쇠를 문에 테이프로 붙여 둔 꼴인데, 코드만 보면
자물쇠가 먼저 보여요. 이것도 깨진 것처럼 보이지 않는다는 점이
견본 ID와 똑같습니다.&lt;/p&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;잘 도는 것이 옳은 것은 아닙니다. 견본 광고는 멀쩡히 뜨고, 평문 코드는
잘 열립니다. 개발 중의 ‘된다’가 출시 후의 ‘맞다’를 보증하지 않아요.&lt;/li&gt;
&lt;li&gt;자리를 더하면 검사에도 더해야 합니다. 광고 자리, 언어 파일, 어떤
목록이든 — 검사 목록의 바깥은 실패조차 못 하는 사각지대입니다.&lt;/li&gt;
&lt;li&gt;검사가 초록인 것과 옳은 것은 다릅니다. 초록은 ‘겨눈 것이 통과했다’는
뜻이고, 무엇을 겨누고 있는지는 주기적으로 다시 물어야 합니다.&lt;/li&gt;
&lt;li&gt;자리 표시자는 ‘안전한 실패’를 고릅니다. 없는 값의 자리에는 그럴듯한
견본이 아니라 빈 값을 — 나가면 사고인 것과 나가면 그저 아쉬운 것은
다른 물건입니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 9 — QR을 찍은 사람이 앱이 있는데도 스토어로 갔어요</title>
    <link href="https://blog.twinklelabs.kr/android/app-links-qr.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/app-links-qr.html</id>
    <published>2026-09-02T10:00:00.000Z</published>
    <updated>2026-09-02T10:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="딥링크"/>
    <category term="App Links"/>
    <category term="QR"/>
    <category term="공유"/>
    <category term="Android"/>
    <summary type="html">공유 그림의 QR이 스토어 주소를 직접 담고 있었습니다. 앱을 이미 깐 사람도 찍으면 스토어로 떨어졌어요. 주소를 우리 도메인으로 바꾸고 App Links를 세우면 링크 하나가 두 경우를 다 받습니다 — 그 구현을 처음부터 끝까지 적었습니다.</summary>
    <content type="html">&lt;p&gt;물타기 계산기에는 계산 결과를 그림으로 공유하는 기능이 있습니다.
그림 구석에 QR이 붙어 있어요 — 받아 본 사람이 찍으면 앱으로 오라는 문이죠.&lt;/p&gt;
&lt;p&gt;그런데 그 QR이 담고 있던 주소가 Play 스토어 직링크였습니다.&lt;/p&gt;
&lt;p&gt;앱이 없는 사람에게는 맞는 문입니다. 문제는 앱을 이미 깐 사람이에요.
찍으면 스토어가 열립니다. “설치됨” 버튼이 있는 스토어 페이지가요.
거기서 ‘열기’를 한 번 더 눌러야 앱에 도착합니다. 문을 잘못 단 거예요.&lt;/p&gt;
&lt;p&gt;원하는 동작은 이겁니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱이 있는 기기: QR을 찍으면 앱이 바로 열린다&lt;/li&gt;
&lt;li&gt;앱이 없는 기기: 웹 페이지를 거쳐 스토어로 간다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;링크 하나가 두 경우를 다 받아야 해요. 이걸 해 주는 게 App Links입니다.&lt;/p&gt;
&lt;h2 id=&quot;딥링크가-뭔지부터&quot;&gt;딥링크가 뭔지부터&lt;/h2&gt;
&lt;p&gt;딥링크(deep link)는 링크를 눌렀을 때 웹 페이지가 아니라
앱의 특정 화면이 열리게 하는 장치입니다. 안드로이드에는 크게 두 세대가 있어요.&lt;/p&gt;
&lt;p&gt;1세대 — 커스텀 스킴. &lt;code&gt;mycalc://open&lt;/code&gt; 같은 주소를 앱이 받겠다고 선언하는
방식입니다. 간단하지만 약점이 커요. 아무 앱이나 같은 스킴을 선언할 수 있어서
누가 받을지 보장이 없고, 앱이 없는 기기에서는 링크가 그냥 죽습니다.
QR에 담기엔 실격이에요 — 앱 없는 사람이 주 대상인데요.&lt;/p&gt;
&lt;p&gt;2세대 — App Links. 그냥 https 주소를 씁니다.
&lt;code&gt;https://twinklelabs.kr/app/stock-calculator&lt;/code&gt; 같은 진짜 웹 주소요.
앱이 있으면 OS가 그 주소를 앱에게 주고, 없으면 브라우저가 여는 —
물러설 곳이 웹인 구조입니다. 대신 “이 도메인의 주소는 내가 받겠다”는 주장을
도메인 주인이 보증해야 해요. 그 보증 절차가 App Links의 전부입니다.&lt;/p&gt;
&lt;h2 id=&quot;구현은-세-조각입니다&quot;&gt;구현은 세 조각입니다&lt;/h2&gt;
&lt;h3 id=&quot;1-매니페스트--앱이-이-주소는-내-것이라고-선언&quot;&gt;1. 매니페스트 — 앱이 ‘이 주소는 내 것’이라고 선언&lt;/h3&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;intent-filter&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;autoVerify&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;    &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;action&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;android.intent.action.VIEW&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt; /&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;    &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;category&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;android.intent.category.DEFAULT&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt; /&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;    &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;category&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;android.intent.category.BROWSABLE&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt; /&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;    &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;        android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;scheme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;https&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;        android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;host&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;twinklelabs.kr&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;        android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;pathPrefix&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;/app/stock-calculator&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt; /&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;intent-filter&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;autoVerify=&quot;true&quot;&lt;/code&gt;가 핵심입니다. 이게 있으면 설치 시점에 OS가
도메인에 가서 보증을 확인하고, 통과하면 그 주소를 묻지 않고 앱으로 엽니다.
(없으면 ‘어느 앱으로 열까요’ 선택지가 뜨거나 브라우저로 가요.)&lt;/p&gt;
&lt;p&gt;여기서 실수하기 쉬운 게 경로의 폭입니다. &lt;code&gt;pathPrefix&lt;/code&gt; 없이 도메인 전체를
잡으면, 소개 화면이든 블로그든 그 도메인의 모든 링크를 앱이 가로챕니다.
블로그 글을 눌렀는데 계산기가 열리는 거예요. 앱이 받아야 할 경로만 좁게 잡습니다.&lt;/p&gt;
&lt;h3 id=&quot;2-assetlinksjson--도메인이-맞아-그-앱-내-것이라고-보증&quot;&gt;2. assetlinks.json — 도메인이 ‘맞아, 그 앱 내 것’이라고 보증&lt;/h3&gt;
&lt;p&gt;도메인 루트의 정해진 자리에 파일을 하나 올립니다.
&lt;code&gt;https://twinklelabs.kr/.well-known/assetlinks.json&lt;/code&gt; —&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;[{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;  &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;relation&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;delegate_permission/common.handle_all_urls&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;  &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;target&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;    &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;namespace&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;android_app&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;    &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;package_name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;kr.twinklelabs.stockcalculator&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;    &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#0DB9D7&quot;&gt;sha256_cert_fingerprints&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;8E:CB:…:03&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;}]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;패키지 이름과 서명 인증서 지문으로 앱을 특정합니다. 아무 앱이나
“twinklelabs.kr은 내 거”라고 우길 수 없는 이유가 이 지문이에요.&lt;/p&gt;
&lt;p&gt;여기 함정이 하나 있습니다. 지문이 두 개예요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;업로드 키 지문: 내가 서명해서 올리는 번들의 키&lt;/li&gt;
&lt;li&gt;앱 서명 키 지문: Play가 사용자에게 다시 서명해서 내보내는 키&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Play 앱 서명을 쓰면(요즘은 거의 다 씁니다) 사용자 기기에 도착하는 APK는
Play의 키로 서명돼 있습니다. 그러니 assetlinks에는 앱 서명 키 지문이
서야 해요. 어디서 읽냐면 — Play Console의 ‘앱 무결성 → 앱 서명’이
assetlinks 스니펫째로 보여 줍니다. 짐작하지 말고 거기서 복사하세요.
(사이드로드 빌드도 열리게 하려면 업로드 키 지문을 배열에 같이 넣으면 됩니다.)&lt;/p&gt;
&lt;h3 id=&quot;3-웹-페이지--앱-없는-기기의-물러설-곳&quot;&gt;3. 웹 페이지 — 앱 없는 기기의 물러설 곳&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;https://twinklelabs.kr/app/stock-calculator&lt;/code&gt;는 실제로 열리는 페이지여야
합니다. 앱 없는 기기에서는 브라우저가 여기로 오니까요. 이 페이지가 하는 일은
스토어로 넘기는 것 하나입니다 — 그리고 넘기면서 &lt;code&gt;utm_source=share&lt;/code&gt; 같은
꼬리표를 대신 붙여 줍니다.&lt;/p&gt;
&lt;p&gt;이게 부수입인데요, QR에 담기는 주소 자체는 짧게 유지하면서
취득 경로 집계는 웹이 이어 주는 구조가 됩니다.&lt;/p&gt;
&lt;h2 id=&quot;설계에서-정한-것-두-가지&quot;&gt;설계에서 정한 것 두 가지&lt;/h2&gt;
&lt;p&gt;주소의 문법은 저장소가 이미 정해 두고 있었어요. 처음에는
&lt;code&gt;/stock-calculator/app&lt;/code&gt;으로 갔다가 &lt;code&gt;/app/stock-calculator&lt;/code&gt;로 바로잡았습니다.
저희 저장소는 제품을 ‘어떻게 사용자 손에 가는가’로 바구니에 나눕니다 —
스토어에 올라가는 앱은 &lt;code&gt;app/&lt;/code&gt; 아래에요. 웹 주소도 같은 문법을 따르면,
다음 앱은 규칙을 새로 정할 것 없이 &lt;code&gt;/app/pocket-pdf&lt;/code&gt;로 &lt;strong&gt;같은 자리에 줄만
서면 됩니다.&lt;/strong&gt; 실제로 같은 날 Pocket PDF가 두 번째 줄에 섰어요 —
매니페스트에 같은 필터, assetlinks에 항목 하나. 그게 다였습니다.&lt;/p&gt;
&lt;p&gt;링크는 유입만 시킵니다. 처음엔 계산 값 전부를 쿼리에 실어서, 받는 쪽이
리포트를 다시 세우는 화면까지 만들었다가 걷었어요. 물리가 반대했거든요 —
QR은 담는 바이트가 늘수록 칸이 잘아져서, 공유 그림 구석의 작은 QR에는
밀도 예산이 있습니다(한 칸 4px, 주소 약 106바이트). 종목 이름을 실은 링크는
그 예산에 들어가지 않았어요. 그리고 생각해 보면 &lt;strong&gt;공유 그림이 이미 리포트
전체를 보여 주고 있습니다.&lt;/strong&gt; 링크가 그걸 또 나를 이유가 없어요.&lt;/p&gt;
&lt;p&gt;이름도 하나 갈았습니다 — &lt;code&gt;share_store_url&lt;/code&gt; → &lt;code&gt;share_app_url&lt;/code&gt;.
가리키는 곳이 스토어가 아니게 됐는데 이름이 그대로면, 다음에 읽는 사람에게
거짓말을 하게 되니까요.&lt;/p&gt;
&lt;h2 id=&quot;마지막-함정--배포되기-전엔-서지-않습니다&quot;&gt;마지막 함정 — 배포되기 전엔 서지 않습니다&lt;/h2&gt;
&lt;p&gt;코드를 다 짜고 머지까지 해도 App Links는 아직 안 섭니다.
&lt;code&gt;.well-known/assetlinks.json&lt;/code&gt;이 실제 도메인에서 서빙되고 있어야
설치 시점 검증이 통과하거든요. 저희 웹은 main 푸시에서만 배포되니,
웹이 나가기 전까지 앱 쪽 코드는 완성돼 있어도 링크는 브라우저로 갑니다.&lt;/p&gt;
&lt;p&gt;검증이 붙었는지는 기기에서 확인할 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; get-app-links&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; kr.twinklelabs.stockcalculator&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;verified&lt;/code&gt;가 떠야 끝난 겁니다.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;커스텀 스킴이 아니라 App Links입니다. QR의 주 대상은 앱 없는 사람이고,
물러설 곳이 웹이어야 링크가 죽지 않아요.&lt;/li&gt;
&lt;li&gt;경로는 좁게. 도메인 전체를 잡으면 앱이 웹사이트를 통째로 가로챕니다.&lt;/li&gt;
&lt;li&gt;assetlinks의 지문은 Play 콘솔에서 읽습니다. 업로드 키 지문을 넣고
‘왜 안 되지’ 하는 게 제일 흔한 함정이에요.&lt;/li&gt;
&lt;li&gt;링크에 무엇을 실을지는 QR의 밀도 예산과 화면이 이미 하는 일이 정합니다.
실을 수 있다고 싣는 게 아니라요.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 6 — «검색되는 PDF»가 한글은 한 글자도 검색하지 못했어요</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/korean-text-layer.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/korean-text-layer.html</id>
    <published>2026-09-02T09:30:00.000Z</published>
    <updated>2026-09-02T09:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="OCR"/>
    <category term="ML Kit"/>
    <category term="PDF"/>
    <category term="폰트"/>
    <category term="Android"/>
    <summary type="html">홈 화면은 «검색되는 문서로 만들어 드려요»라고 말하고 있었습니다. 그런데 코드 깊은 곳에는 «코드값 256 위는 공백으로»라는 한 줄이 있었어요. 한글은 U+AC00부터 시작합니다. 주 시장의 언어가 통째로 공백이 되고 있었습니다.</summary>
    <content type="html">&lt;p&gt;Pocket PDF의 홈 화면은 이렇게 말합니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;찍은 문서가 검색되는 깨끗한 새 문서로&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;빈 화면도 같은 말을 해요 — ‘자동 보정 후 검색 가능한 PDF로’.&lt;/p&gt;
&lt;p&gt;그런데 한국어 계약서를 스캔해서 만든 PDF에서 ‘계약서’를 검색하면,
아무것도 나오지 않았습니다. 한 글자도요.&lt;/p&gt;
&lt;p&gt;앱은 멀쩡히 돌았고, PDF도 멀쩡히 만들어졌고, 화면에는 글자가 또렷이 보였어요.
그래서 아무도 몰랐습니다. 이 앱의 주 시장이 한국인데도요.&lt;/p&gt;
&lt;h2 id=&quot;먼저-ocr이-뭘-하는-건지부터&quot;&gt;먼저, OCR이 뭘 하는 건지부터&lt;/h2&gt;
&lt;p&gt;스캔한 문서는 글자처럼 보이는 그림입니다. 사람 눈에는 ‘계약서’지만,
컴퓨터에게는 픽셀 덩어리예요. 그림 속 글자는 검색도, 복사도 안 됩니다.&lt;/p&gt;
&lt;p&gt;OCR(Optical Character Recognition, 광학 문자 인식)은 그 그림을 읽어서
‘어느 자리에 어떤 글자가 있다’는 데이터로 바꾸는 일입니다.
“이 사각형 안의 픽셀은 ‘계’라는 글자다” — 이걸 문서 전체에 대해 알아내는 거예요.&lt;/p&gt;
&lt;p&gt;그런데 OCR을 돌렸다고 끝이 아닙니다. 그 결과를 PDF 안에 다시 넣어야
검색이 되거든요. 여기에 쓰는 게 보이지 않는 글자층(invisible text layer)입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;눈에 보이는 것: 스캔한 그림 그대로&lt;/li&gt;
&lt;li&gt;그 위(정확히는 같은 자리)에: 투명한 진짜 글자&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PDF 뷰어에서 스캔본의 글자를 드래그하면 선택이 되는 문서들, 다 이 구조입니다.
그림 위에 얹힌 투명한 글자를 선택하고 있는 거예요.&lt;/p&gt;
&lt;h2 id=&quot;ml-kit--기기-안에서-읽습니다&quot;&gt;ML Kit — 기기 안에서 읽습니다&lt;/h2&gt;
&lt;p&gt;Pocket PDF의 OCR은 구글 ML Kit의 Text Recognition을 씁니다.
서버로 아무것도 보내지 않고 기기 안에서 돌아요 — ‘문서가 밖으로 나가지 않는다’가
이 앱의 약속이라, 온디바이스는 선택이 아니라 전제였습니다.&lt;/p&gt;
&lt;p&gt;적용은 생각보다 단순합니다. 인식기를 하나 만들고,&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; recognizer &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; TextRecognition.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getClient&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    KoreanTextRecognizerOptions.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;Builder&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;build&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;비트맵을 넣으면 결과가 나옵니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; inputImage &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; InputImage.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;fromBitmap&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(bitmap, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; visionText &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; recognizer.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;process&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(inputImage).&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 게 두 가지예요.&lt;/p&gt;
&lt;p&gt;하나, 인식기는 언어별입니다. &lt;code&gt;KoreanTextRecognizerOptions&lt;/code&gt;는 한글과 라틴을
읽습니다. 일본어·중국어를 읽으려면 다른 인식기를 써야 하고, 모델도 각자 딸려 와요.
“어떤 글자가 나올 수 있는가”는 인식기가 정합니다 — 이 사실이 뒤에서 다시 나옵니다.&lt;/p&gt;
&lt;p&gt;둘, 결과는 글자만이 아니라 자리를 들고 옵니다. ML Kit은 결과를
블록 → 줄 → 낱말(element)의 세 층으로 주는데, 층마다 &lt;code&gt;boundingBox&lt;/code&gt; —
그 글자가 그림의 어느 사각형에 있었는지 — 가 붙어 있어요.&lt;/p&gt;
&lt;p&gt;이 사각형이 글자층의 전부입니다. 낱말 ‘보증금’이 그림의 (320, 480)에서
90×40 픽셀이었다면, PDF에서도 그림이 놓인 자리에 맞춰 환산한 그 사각형에
투명한 ‘보증금’을 그립니다. 그래야 검색해서 하이라이트되는 자리와
눈에 보이는 글자가 겹쳐요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;setRenderingMode&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(RenderingMode.NEITHER)  &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 그리지도, 외곽선도 없이&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;beginText&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;setFont&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(font, height &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 0.85f&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;newLineAtOffset&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(left, bottom)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;showText&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(text)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;contentStream.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;endText&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;RenderingMode.NEITHER&lt;/code&gt;가 ‘보이지 않는’의 정체입니다. 글자를 배치는 하되
잉크를 칠하지 않아요.&lt;/p&gt;
&lt;h2 id=&quot;그런데-폰트가-문제였습니다&quot;&gt;그런데 폰트가 문제였습니다&lt;/h2&gt;
&lt;p&gt;PDF에 글자를 그리려면 폰트가 필요합니다. 여기서 편한 길이 하나 있어요 —
PDF 규격에는 표준 14 폰트라는 게 있어서, Helvetica 같은 폰트는
파일을 싣지 않고도 이름만으로 쓸 수 있습니다.&lt;/p&gt;
&lt;p&gt;글자층은 그 편한 길로 그려지고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; font &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; PDType1Font.HELVETICA&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;공짜에는 대가가 있습니다. 표준 14 폰트는 &lt;strong&gt;WinAnsi(Latin-1) 인코딩 밖을
아예 못 그립니다.&lt;/strong&gt; 코드값 255까지 — 라틴 알파벳과 서유럽 문자까지만요.&lt;/p&gt;
&lt;p&gt;그래서 코드에는 이런 방어가 들어 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;ch.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;isLetterOrDigit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; ch.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;isWhitespace&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    sb.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;append&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (ch.code &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 256&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) ch &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; ' '&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;코드값 256 위는 공백으로.&lt;/p&gt;
&lt;p&gt;한글 음절은 U+AC00(44032)부터 시작합니다. 전부 256 위예요.
그러니까 한국어 문서의 글자층은 — OCR이 완벽하게 읽어냈어도 —
한 줄이 통째로 공백이 되어 PDF에 들어가고 있었습니다.&lt;/p&gt;
&lt;p&gt;이게 ‘조용히’의 정체입니다. 예외도 안 났고, 로그도 없었고, PDF는 잘 열렸어요.
방어 코드가 제 일을 성실하게 한 결과가, 화면의 약속을 거짓말로 만들고
있었습니다. 이건 ‘아직 못 하는 일’이 아니에요 — 화면이 된다고 말하고 있었으니까,
거짓말입니다.&lt;/p&gt;
&lt;h2 id=&quot;찾으러-가서-찾은-게-아닙니다&quot;&gt;찾으러 가서 찾은 게 아닙니다&lt;/h2&gt;
&lt;p&gt;이 버그를 발견한 경위가 저는 제일 남습니다.&lt;/p&gt;
&lt;p&gt;그날 하던 일은 버그 사냥이 아니라 중복 제거였어요. 글자층을 얹는 자리가
둘이 되면서(스캔 → PDF, 이미지 → PDF) 좌표 계산이 두 벌로 갈릴 참이라,
&lt;code&gt;InvisibleTextLayer&lt;/code&gt; 한 곳으로 모으고 있었습니다.&lt;/p&gt;
&lt;p&gt;코드를 옮기려면 읽어야 하죠. 그래서 몇 달 동안 아무도 다시 읽지 않던
&lt;code&gt;sanitize&lt;/code&gt; 함수를 읽게 됐고, 그 안의 ‘256 위는 공백으로’를 봤습니다.&lt;/p&gt;
&lt;p&gt;중복을 걷는 일의 명분은 코드 줄 수를 줄이는 겁니다. 그런데 실제로 얻은 건
그 안에 있던 것을 다시 보게 되는 일이었어요. 두 벌로 흩어져 있는 동안에는
어느 쪽도 다시 읽힐 기회가 없었습니다.&lt;/p&gt;
&lt;h2 id=&quot;고치기--폰트를-받아-오지-않고-구웠습니다&quot;&gt;고치기 — 폰트를 받아 오지 않고 구웠습니다&lt;/h2&gt;
&lt;p&gt;한글을 그리려면 한글 글리프가 있는 폰트를 PDF에 실어야 합니다.
새 폰트를 받아올 필요는 없었어요 — 앱에 이미 Pretendard(OFL)가 있고,
라이선스 고지도 이미 서 있었으니까요.&lt;/p&gt;
&lt;p&gt;다만 두 가지 손질이 필요했습니다. &lt;code&gt;tools/bake-ocr-font.py&lt;/code&gt;라는 스크립트를
저장소에 두고 거기서 합니다.&lt;/p&gt;
&lt;p&gt;하나, 필요한 글자만 남깁니다(서브셋). 여기서 기준이 중요한데 —
담을 글자는 ‘앱이 지원하는 열한 언어’가 아니라 ‘OCR이 뱉을 수 있는 글자’입니다.
인식기가 &lt;code&gt;KoreanTextRecognizerOptions&lt;/code&gt; 하나뿐이면 일본어 글자는 애초에
만들어지지 않아요. 만들어지지 않는 글자의 글리프를 싣는 건 낭비입니다.
그래서 한글 음절 11,172자 + 라틴 + 통화 기호(₩ € £) + 문장부호, 모두 11,721자.&lt;/p&gt;
&lt;p&gt;둘, OTF를 TrueType으로 옮깁니다. Pretendard는 OTF(CFF)인데 PDFBox의
&lt;code&gt;PDType0Font&lt;/code&gt;는 TrueType의 &lt;code&gt;glyf&lt;/code&gt;를 읽습니다. cu2qu로 곡선 문법을 바꿔요 —
모양을 바꾸는 게 아니라 같은 모양을 다른 문법으로 다시 적는 일입니다.&lt;/p&gt;
&lt;p&gt;이렇게 구운 2.14 MB짜리 폰트를 assets에 싣고, 이제 이렇게 로드합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;context.assets.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;open&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;ocr_text_layer.ttf&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;use&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    PDType0Font.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;load&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(document, it, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* embedSubset = */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;“2 MB를 실으면 PDF마다 2 MB씩 커지는 거 아냐?” — 아닙니다.
&lt;code&gt;embedSubset = true&lt;/code&gt;면 실제로 그린 글자만 PDF에 담겨요. 재어 보니
한글 한 줄을 담은 PDF가 4 KB였습니다. 커지는 건 APK뿐이고, 그건 한 번 치르면
끝입니다.&lt;/p&gt;
&lt;h2 id=&quot;만들어졌다와-된다는-다른-말이라서&quot;&gt;‘만들어졌다’와 ‘된다’는 다른 말이라서&lt;/h2&gt;
&lt;p&gt;고치고 나서, 진짜로 검색이 되는지를 확인해야 했습니다. 그런데
‘폰트가 실렸다’는 확인이 아니에요. 실리고도 검색이 안 될 수 있거든요.&lt;/p&gt;
&lt;p&gt;PDF에서 검색을 지는 것은 ToUnicode라는 표입니다 — “이 글리프는
유니코드로 이 글자다”라는 대응표요. 그래서 시험도 그걸 겨눴습니다.
pdfbox-android를 JVM에서 직접 돌려서, 만들어진 PDF의 ToUnicode를 되읽어&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;계약서 임대차 보증금 ₩1,850,000 café&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;가 그대로 나오는 것까지 봤습니다. 이게 나오면 어떤 PDF 뷰어든 찾을 수 있어요.
‘폰트가 실린다’와 ‘PDF가 4 KB다’는 ‘만들어졌다’만 말합니다.
검색이 되는지는 ToUnicode가 집니다.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;성실한 방어 코드가 조용한 거짓말이 될 수 있습니다. ‘256 위는 공백으로’는
표준 폰트의 한계에 대한 올바른 방어였어요. 다만 그 방어가 화면의 약속과
부딪히는지는 아무도 세지 않았습니다.&lt;/li&gt;
&lt;li&gt;중복 제거의 진짜 소득은 줄어든 줄 수가 아니라, 다시 읽게 되는 일입니다.&lt;/li&gt;
&lt;li&gt;폰트가 덮어야 하는 건 앱의 언어가 아니라 인식기가 뱉는 글자입니다.
경계는 화면이 아니라 데이터가 태어나는 자리에 있어요.&lt;/li&gt;
&lt;li&gt;‘만들어졌다’는 ‘된다’가 아닙니다. 시험은 결과물을 되읽는 데까지 가야 합니다.&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 5 — 로그가 세던 숫자와 빌드가 세던 숫자가 달랐어요</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/two-counters-one-number.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/two-counters-one-number.html</id>
    <published>2026-08-26T02:20:00.000Z</published>
    <updated>2026-08-26T02:20:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="출시"/>
    <category term="자동화"/>
    <category term="CI"/>
    <category term="버전"/>
    <summary type="html">버전 코드를 올리는 커밋이 하루에 세 개 쌓였어요. 8 → 14 → 20. 원인을 따라가 보니 숫자를 세는 사람이 둘이었고, CI 로그는 몇 판째 다른 숫자를 찍고 있었습니다.</summary>
    <content type="html">&lt;p&gt;새벽에 앱을 하나 내보내는 데 커밋이 세 개 들었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0948ce4  chore: release v1.0.5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;088c36b  fix: bump version code to 14 due to previous versions&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;6358847  fix: bump version code to 20 to surpass CI generated codes&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메시지 두 줄이 사고 전말이에요.
“due to previous versions”, 그리고 “to surpass CI generated codes”.&lt;/p&gt;
&lt;p&gt;Play 콘솔이 “이미 쓴 번호”라며 계속 거절했고, 얼마나 올려야 통과하는지 몰라서
8 → 14 → 20 으로 더듬어 올라간 흔적입니다.&lt;/p&gt;
&lt;p&gt;낮에 그 판을 다시 열어봤어요.
숫자가 부딪힌 게 아니라 숫자를 세는 사람이 둘이었습니다.&lt;/p&gt;
&lt;h2 id=&quot;세는-사람이-둘&quot;&gt;세는 사람이 둘&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;versionCode&lt;/code&gt; 는 CI 가 셉니다. 이런 식이에요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; lastManualVersionCode &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 20&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;versionCode &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; System.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getenv&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;GITHUB_RUN_NUMBER&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)?.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;toIntOrNull&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    ?.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;plus&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(lastManualVersionCode) ?: lastManualVersionCode&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main&lt;/code&gt; 에 밀면 GitHub Actions 가 돌고, 실행 번호가 더해져서 하나씩 올라갑니다.
사람은 손댈 일이 없어요. 좋은 구조입니다.&lt;/p&gt;
&lt;p&gt;문제는 그날 새벽에 &lt;code&gt;main&lt;/code&gt; 을 거치지 않았다는 거예요.
로컬에서 &lt;code&gt;publishReleaseBundle&lt;/code&gt; 로 곧장 올렸거든요.&lt;/p&gt;
&lt;p&gt;로컬에는 &lt;code&gt;GITHUB_RUN_NUMBER&lt;/code&gt; 가 없죠.
그래서 &lt;code&gt;?:&lt;/code&gt; 뒤가 실행됩니다 — 상수가 그대로 versionCode 가 돼요.&lt;/p&gt;
&lt;p&gt;그동안 CI 는 8 부터 19 까지 이미 태워둔 상태였습니다.
로컬은 자기가 8 이라고 굳게 믿고 내밀었고, 콘솔은 이미 19 를 보고 있었어요.&lt;/p&gt;
&lt;p&gt;여기서 중요한 건 틀렸다는 걸 부딪힌 뒤에야 안다는 점이에요.
로컬은 CI 가 어디까지 셌는지 알 방법이 없습니다.
그리고 이 부딪힘은 되돌릴 수가 없어요 — 콘솔이 한 번 본 번호는 다시 받지 않으니까,
잘못 태운 번호는 영영 태운 겁니다.&lt;/p&gt;
&lt;p&gt;값이라면 두 곳에 적어두고 맞추면 그만인데,
세는 행위는 두 곳에서 할 수가 없어요.&lt;/p&gt;
&lt;h2 id=&quot;그리고-로그는-몇-판째-거짓말을-하고-있었어요&quot;&gt;그리고 로그는 몇 판째 거짓말을 하고 있었어요&lt;/h2&gt;
&lt;p&gt;원인을 따라가다가 워크플로에서 이 줄을 봤습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; Play 에 올리기&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;  run&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;    echo &quot;track=$TRACK versionCode=$((GITHUB_RUN_NUMBER + 7))&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;    ./gradlew publishReleaseBundle --track &quot;$TRACK&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;+ 7&lt;/code&gt; 이요.
정본(&lt;code&gt;build.gradle.kts&lt;/code&gt;)은 이미 20 인데 로그는 7 을 더하고 있었어요.&lt;/p&gt;
&lt;p&gt;이게 이 시리즈에 어울리는 이유가 여기 있습니다.&lt;/p&gt;
&lt;p&gt;빌드는 정본을 봅니다. Gradle 이 &lt;code&gt;build.gradle.kts&lt;/code&gt; 를 읽으니까요.
그러니까 실제로 올라간 번들의 versionCode 는 늘 맞았어요.
틀린 건 로그 한 줄뿐이었습니다.&lt;/p&gt;
&lt;p&gt;결과가 계속 옳았기 때문에 아무도 이상하다고 느낄 계기가 없었어요.
옳은 결과가 틀린 설명을 덮어준 셈입니다.&lt;/p&gt;
&lt;p&gt;값은 나중에 치릅니다.
누군가 그 로그를 믿고 “지금 몇 번까지 갔지?” 하고 판단하는 순간에요.
13 만큼 틀린 답을 아주 자신 있게 받게 됩니다.&lt;/p&gt;
&lt;p&gt;고친 방법은 단순해요. 옮겨 적지 말고 그 자리에서 읽습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;run&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;  # 상수를 여기 옮겨 적지 않는다 — 옮겨 적은 순간 정본과 갈린다.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;  LAST_MANUAL=$(grep -oE 'val lastManualVersionCode *= *[0-9]+' app/build.gradle.kts | grep -oE '[0-9]+$')&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;  test -n &quot;$LAST_MANUAL&quot; || { echo &quot;lastManualVersionCode 를 못 읽었다&quot;; exit 1; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;  echo &quot;track=$TRACK versionCode=$((GITHUB_RUN_NUMBER + LAST_MANUAL))&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;못 읽으면 죽게 했어요.
셸에서 정의되지 않은 변수는 산술 확장에서 조용히 0 이 되거든요.
그러면 로그는 다시 그럴듯한 숫자를 찍고, 우리는 같은 자리를 두 번 파게 됩니다.&lt;/p&gt;
&lt;p&gt;틀린 숫자를 자신 있게 찍느니 죽는 편이 낫습니다.&lt;/p&gt;
&lt;h2 id=&quot;이름도-되돌려-쓸-뻔했어요&quot;&gt;이름도 되돌려 쓸 뻔했어요&lt;/h2&gt;
&lt;p&gt;그날 나간 1.0.5 에는 사실 회귀가 넷 실려 있었습니다.
“거대 컴포넌트를 분리한다”고 적힌 리팩토링 커밋이 실제로는 컴포넌트를 다시 쓴 거였고,
그 과정에서 여백 하나, 배지 하나, 애니메이션 키 하나, 미리보기 하나가 사라졌어요.
(이건 따로 쓸 만한 이야기라 여기선 접어둘게요.)&lt;/p&gt;
&lt;p&gt;넷을 되돌리고 나서, 하마터면 그 판을 다시 1.0.5 로 내보낼 뻔했습니다.
&lt;code&gt;versionName&lt;/code&gt; 이 그대로였거든요.&lt;/p&gt;
&lt;p&gt;versionCode 는 CI 가 올려주니까 충돌은 안 났을 거예요.
콘솔도 조용히 받았을 거고요.&lt;/p&gt;
&lt;p&gt;그런데 그러면 세상에 1.0.5 가 두 개 존재하게 됩니다.
하나는 배지가 안 뜨는 1.0.5, 하나는 뜨는 1.0.5요.&lt;/p&gt;
&lt;p&gt;“제 폰에 깔린 1.0.5 는 어느 쪽인가요” 라는 질문에 아무도 답할 수 없어요.
태그도, 출시 기록도, 콘솔의 트랙 이력도 전부 그 이름 하나로 서 있으니까요.&lt;/p&gt;
&lt;p&gt;고친 판은 1.0.6 으로 나갔습니다.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;p&gt;넷 다 뿌리가 하나였어요.&lt;/p&gt;
&lt;p&gt;번호는 “값”이 아니라 “세는 행위”인데, 그 행위를 둘이 하고 있었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;세는 것은 한 곳에서만 셉니다. 로컬 업로드는 “급할 때의 우회로”가 아니라
번호를 부딪히게 만드는 길이에요. 출시는 &lt;code&gt;main&lt;/code&gt; 을 지납니다.&lt;/li&gt;
&lt;li&gt;상수는 옮겨 적지 말고 그 자리에서 읽습니다. 옮겨 적은 값은 처음엔 맞아서
갈린 순간을 아무도 못 봐요.&lt;/li&gt;
&lt;li&gt;버전 이름은 재활용하지 않습니다.&lt;/li&gt;
&lt;li&gt;태그와 출시 기록까지가 한 판입니다. 태그가 없으면 다음 판에서
“어디서부터 어디까지였나”를 잃어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 하나 더요.&lt;/p&gt;
&lt;p&gt;이 사고의 이음매를 따라가 보니, 역할을 나눠둔 자리에도 같은 모양이 있었습니다.
번호를 정하는 담당과 올리는 담당을 따로 두었는데, 정작 올리는 쪽만 불렸고
번호는 아무도 정하지 않은 채 옛 이름으로 나갈 뻔했거든요.&lt;/p&gt;
&lt;p&gt;둘로 나뉜 일은 한쪽만 불리면 무너집니다.
나누는 게 항상 좋은 건 아니더라고요.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 4 — 정본 주소가 404를 가리켜도 화면은 멀쩡히 뜹니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/seo-head-lies.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/seo-head-lies.html</id>
    <published>2026-08-24T03:40:00.000Z</published>
    <updated>2026-08-24T03:40:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="seo"/>
    <category term="astro"/>
    <category term="nextjs"/>
    <category term="정적사이트"/>
    <category term="자동화"/>
    <summary type="html">사이트 네 곳에 SEO를 붙이다가 세 가지를 찾았어요. 정본 주소가 없는 페이지를 가리키고 있었고, 한자를 거르려던 정규식이 한글을 통째로 삼켰고, 그래서 카드가 한 장도 안 구워졌는데 빌드는 성공했습니다.</summary>
    <content type="html">&lt;p&gt;앞의 세 편은 빌드와 배포가 조용히 실패한 이야기였어요.
이번 편은 자리가 다릅니다. &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt; 안이에요.&lt;/p&gt;
&lt;p&gt;여기가 고약한 이유는 하나예요.
사람이 안 보는 곳이라서, 틀려도 화면은 멀쩡히 뜹니다.&lt;/p&gt;
&lt;p&gt;사이트 네 곳(홈·디자인 시스템·약관 보관소·이 블로그)에 SEO를 붙이면서
세 가지를 찾았어요. 셋 다 빌드는 초록이었습니다.&lt;/p&gt;
&lt;h1 id=&quot;1-정본-주소가-없는-페이지를-가리키고-있었어요&quot;&gt;1. 정본 주소가 없는 페이지를 가리키고 있었어요&lt;/h1&gt;
&lt;p&gt;이 블로그는 예전 Pelican 시절의 주소를 그대로 지키고 있어요.
그래서 어떤 페이지는 &lt;code&gt;.html&lt;/code&gt;로 끝납니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;/android/aapt-string-newline.html&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;/archives.html&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정본 주소(canonical)는 이렇게 만들고 있었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; canonical&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-weight:bold;--shiki-dark:#89DDFF;--shiki-dark-font-weight:inherit&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; URL&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#C0CAF5&quot;&gt;Astro&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#C0CAF5&quot;&gt;url&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#7DCFFF&quot;&gt;pathname&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#0DB9D7&quot;&gt; SITE&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#7DCFFF&quot;&gt;url&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#7DCFFF&quot;&gt;href&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;문제는 &lt;code&gt;Astro.url.pathname&lt;/code&gt;이 그 주소를 다듬어서 내놓는다는 거예요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;실제 주소   /android/aapt-string-newline.html&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;pathname   /android/aapt-string-newline/&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;뒤쪽 주소는 어디에도 없습니다. 깃허브 페이지에서 404예요.
즉 모든 글의 정본이 404를 가리키고 있었습니다.&lt;/p&gt;
&lt;p&gt;정본이 404면 그 페이지는 색인에서 조용히 빠져요.
그런데 화면은 멀쩡히 뜨고, 링크도 다 잘 돌아갑니다.
틀린 건 사람이 안 여는 &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt; 한 줄이에요.&lt;/p&gt;
&lt;p&gt;고치는 건 간단했어요. 레이아웃이 주소를 추측하지 않게 하고,
부르는 쪽이 실제 주소를 주게 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;astro&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#0DB9D7&quot;&gt;Base&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; path&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#7DCFFF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;postUrl&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#C0CAF5&quot;&gt;post&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#7DCFFF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;      &amp;#x3C;!-- /android/foo.html --&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#0DB9D7&quot;&gt;Base&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; path&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;/archives.html&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;주소를 아는 쪽이 주소를 준다.
레이아웃은 지금 어느 페이지인지 모르는 게 정상이에요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;2-한자를-거르려던-정규식이-한글을-통째로-삼켰어요&quot;&gt;2. 한자를 거르려던 정규식이 한글을 통째로 삼켰어요&lt;/h1&gt;
&lt;p&gt;공유 카드(OG 이미지)를 코드로 굽기로 했어요.
글꼴은 Pretendard를 쓰는데, 여기엔 한자가 없습니다.
라틴·한글·가나까지만 있어요.&lt;/p&gt;
&lt;p&gt;없는 글자를 그리면 두부(□)가 나옵니다.
그래서 “한자가 섞였으면 카드를 굽지 말자”는 검사를 넣었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 이렇게 적었습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; HAN&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#89DDFF&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#E0AF68&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DC8A78;--shiki-dark:#E0AF68&quot;&gt;㐀-䶿一-鿿豈-﫿&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#E0AF68&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#89DDFF&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;범위를 글자 그대로 적었죠. 마지막 범위의 시작을 &lt;code&gt;豈&lt;/code&gt;로 잡았는데,
호환 한자 블록의 첫 글자(U+F900)라고 알고 있었어요.&lt;/p&gt;
&lt;p&gt;아니었습니다. &lt;code&gt;豈&lt;/code&gt;는 U+8C48이에요.&lt;/p&gt;
&lt;p&gt;그래서 생긴 범위가 이겁니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;의도한 것   U+F900 – U+FAFF   (호환 한자)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;실제로 적힌 것  U+8C48 – U+FAFF   ← 한글(U+AC00~U+D7A3)이 이 안에 있어요&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;한글이 통째로 “한자”로 판정됐어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#0DB9D7&quot;&gt;HAN&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;test&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;안드로이드를 만들며 배운 것&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;  // → true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과가 뭐였냐면 — 카드가 한 장도 안 구워졌습니다.
모든 글이 “못 그리는 글”로 걸러졌으니까요.&lt;/p&gt;
&lt;p&gt;그런데 빌드는 성공했어요.
“구울 것이 없다”와 “구울 게 있는데 실패했다”를 빌드는 구분하지 않거든요.
0개를 구우면 0개를 굽는 데 성공한 겁니다.&lt;/p&gt;
&lt;p&gt;고친 방법은 하나예요. 범위를 코드포인트로 적는 것.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 글자가 아니라 번호로. 눈으로 맞히려 드는 순간 같은 실수가 또 납니다.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; HAN&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#89DDFF&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#E0AF68&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#9ECE6A&quot;&gt;\u3400&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DC8A78;--shiki-dark:#E0AF68&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#9ECE6A&quot;&gt;\u4DBF\u4E00&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DC8A78;--shiki-dark:#E0AF68&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#9ECE6A&quot;&gt;\u9FFF\uF900&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DC8A78;--shiki-dark:#E0AF68&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#9ECE6A&quot;&gt;\uFAFF&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#E0AF68&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#EA76CB;--shiki-dark:#89DDFF&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;사람이 눈으로 “이 글자가 그 블록의 첫 자”를 맞히려 드는 순간
이런 오차가 생겨요. 그리고 눈으로는 절대 안 잡힙니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;3-그래서-0개-생성을-아무도-이상하게-보지-않았어요&quot;&gt;3. 그래서 “0개 생성”을 아무도 이상하게 보지 않았어요&lt;/h1&gt;
&lt;p&gt;2번이 정말 무서웠던 건 정규식 실수 자체가 아니에요.
아무도 이상하다고 말하지 않았다는 것이었어요.&lt;/p&gt;
&lt;p&gt;빌드 로그는 이랬습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;✓ Completed in 267ms.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[build] 68 page(s) built&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;68장이 만들어졌으니 다 잘된 것 같죠.
카드가 0장이라는 사실은 로그 어디에도 없어요.
만들지 않은 것은 로그에 남지 않으니까요.&lt;/p&gt;
&lt;p&gt;제가 이걸 잡은 건 우연에 가까웠어요.
결과물 폴더를 열어보다가 &lt;code&gt;dist/og/&lt;/code&gt;에 파일이 하나뿐인 걸 봤습니다.&lt;/p&gt;
&lt;p&gt;그래서 규칙을 하나 더했어요. 앞의 세 편에서 세운 것과 같은 규칙이에요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;빌드가 끝나면 결과물을 세어본다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;지금은 구운 HTML을 전부 훑는 검사를 돌립니다.
빠진 것을 찾는 게 아니라 가리키는 것이 실제로 있는지를 봐요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;── blog ─ HTML 68장 (색인 65) · robots O · sitemap 65 URL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   ✗ /android/…: canonical 이 없는 주소&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   ✗ /android/…: og:image 가 없는 파일&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 두 줄이 1번과 2번을 동시에 잡아줬어요.&lt;/p&gt;
&lt;h1 id=&quot;덤--도구가-조용히-다르게-한-일-셋&quot;&gt;덤 — 도구가 조용히 다르게 한 일 셋&lt;/h1&gt;
&lt;p&gt;같은 작업에서 만난, 같은 성질의 것들이에요.
전부 “에러는 안 나는데 결과가 다른” 종류입니다.&lt;/p&gt;
&lt;h2 id=&quot;nextjs는-opengraph를-합쳐주지-않아요&quot;&gt;Next.js는 &lt;code&gt;openGraph&lt;/code&gt;를 합쳐주지 않아요&lt;/h2&gt;
&lt;p&gt;레이아웃에 카드 이미지를 정해두고, 페이지에서 제목만 덮어썼어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// layout.tsx — 이미지·사이트 이름을 정함&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#7DCFFF&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; metadata&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#73DACA&quot;&gt; openGraph&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#73DACA&quot;&gt; images&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-light-font-weight:inherit;--shiki-dark:#F7768E;--shiki-dark-font-weight:bold&quot;&gt;...&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#73DACA&quot;&gt; siteName&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-light-font-weight:inherit;--shiki-dark:#F7768E;--shiki-dark-font-weight:bold&quot;&gt; ...&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// page.tsx — 제목만 적었는데&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#7DCFFF&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; metadata&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#73DACA&quot;&gt; openGraph&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#73DACA&quot;&gt; title&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; '&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;...&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이러면 이미지가 통째로 사라집니다.
Next는 이 칸을 필드 단위로 합치지 않고 통째로 갈아 끼워요.&lt;/p&gt;
&lt;p&gt;에러는 안 나요. 그냥 카드에 그림이 없어집니다.&lt;/p&gt;
&lt;h2 id=&quot;satori는-자간을-주면-한글-사이-공백-하나를-삼켜요&quot;&gt;satori는 자간을 주면 한글 사이 공백 하나를 삼켜요&lt;/h2&gt;
&lt;p&gt;카드의 작은 머리말에 &lt;code&gt;letterSpacing: 2&lt;/code&gt;를 줬더니 이렇게 나왔어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;넣은 글자   안드로이드를 만들며 배운 것&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;그려진 글자  안드로이드를만들며 배운 것&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;공백 하나만 사라집니다. 나머지는 멀쩡해요.
멋보다 낱말이 붙지 않는 쪽이 나으니 자간을 걷었습니다.&lt;/p&gt;
&lt;h2 id=&quot;정적-내보내기에서-og-이미지는-확장자-없이-나와요&quot;&gt;정적 내보내기에서 OG 이미지는 확장자 없이 나와요&lt;/h2&gt;
&lt;p&gt;Next의 &lt;code&gt;opengraph-image&lt;/code&gt; 규약을 그대로 쓰면
정적 내보내기에서 확장자 없는 파일이 남아요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;out/opengraph-image      ← .png 가 아니에요&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;깃허브 페이지는 확장자로 “이게 무엇인지”를 정합니다.
그러니 이 파일은 그림이 아니라 “내려받을 덩어리”가 되고,
카드를 긁어가는 쪽(트위터·슬랙)은 그걸 그림으로 읽지 않아요.&lt;/p&gt;
&lt;p&gt;주소를 우리가 짓는 걸로 바꿨습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;src/app/og.png/route.tsx  →  out/og.png   ← 진짜 PNG&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;p&gt;1 도구가 잘못 판정했고, 2 도구가 통째로 실패했는데 말하지 않았고,
3 검사가 비교할 대상을 잃고도 그냥 떨어졌고,
4 결과물이 0개인데 성공했습니다.&lt;/p&gt;
&lt;p&gt;앞의 세 편에서 이렇게 적었어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;자동화의 끝은 “돌렸다”가 아니라 “확인했다”입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이번 편이 거기에 한 줄을 더해요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;확인은 “에러가 없다”가 아니라 “있어야 할 것이 있다”입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt;는 특히 그래요.
화면이 멀쩡하다는 건 아무 증거가 못 됩니다.
거긴 사람이 안 보는 자리라서, 틀린 채로 몇 달도 갈 수 있어요.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 8 — MainActivity 다이어트 - 생명주기를 Compose 트리로 내렸어요</title>
    <link href="https://blog.twinklelabs.kr/android/main-activity-lifecycle-compose.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/main-activity-lifecycle-compose.html</id>
    <published>2026-08-23T18:40:00.000Z</published>
    <updated>2026-08-23T18:40:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="compose"/>
    <category term="lifecycle"/>
    <category term="구조"/>
    <category term="리팩토링"/>
    <summary type="html">결제와 인앱 업데이트가 Activity의 생명주기를 요구해서 MainActivity가 온갖 SDK의 진입점이 돼 있었어요. onResume·onDestroy 오버라이드를 걷어내고 그 책임을 컴포저블 하나로 내린 이야기입니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 이벤트를 한 입구로 모은 이야기를 했어요.
콜백 열세 개를 &lt;code&gt;CalculatorAction&lt;/code&gt; 하나로 묶어서, 화면이 사실만 전달하게 만든 이야기였죠.&lt;/p&gt;
&lt;p&gt;이 시리즈의 마지막인 이번 글은 생명주기입니다.
그리고 이건 앱을 만들어보신 분이면 다들 하나씩 갖고 계실 거예요.
어느새 온갖 SDK의 진입점이 되어버린 그 클래스요.&lt;/p&gt;
&lt;h1 id=&quot;mainactivity가-만능-객체가-돼-있었어요&quot;&gt;&lt;code&gt;MainActivity&lt;/code&gt;가 만능 객체가 돼 있었어요&lt;/h1&gt;
&lt;p&gt;앱을 만들다 보면 자연스럽게 &lt;code&gt;MainActivity&lt;/code&gt;가 여러 매니저와 SDK의 진입점이 됩니다.
결제(Billing), 인앱 업데이트(In-app Update), 전면 광고 같은 것들이
전부 Activity의 &lt;code&gt;Context&lt;/code&gt;와 생명주기를 강하게 요구하거든요.&lt;/p&gt;
&lt;p&gt;그래서 코드가 이렇게 생겨 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;override&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; onResume&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#F7768E&quot;&gt;    super&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onResume&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    updateGate.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onResume&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;override&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; onDestroy&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    updateGate.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onDestroy&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (::&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;billing&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.isInitialized) billing.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;close&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#F7768E&quot;&gt;    super&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onDestroy&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동작은 잘 했어요. 그런데 두 가지가 걸렸습니다.&lt;/p&gt;
&lt;p&gt;하나. UI 전체를 Compose로 덮은 선언적 환경에서
Activity가 구체적인 생명주기를 직접 관리하고 있는 건 명령형 시절의 잔재예요.&lt;/p&gt;
&lt;p&gt;둘. 이게 더 실질적인 문제였는데,
&lt;code&gt;if (::billing.isInitialized)&lt;/code&gt; 같은 방어 코드가 왜 필요했느냐면
Activity가 언제 무엇을 들고 있는지 자기도 확신할 수 없었기 때문입니다.&lt;/p&gt;
&lt;p&gt;5편에서 봤던 것과 같은 냄새였어요. 여기에 SDK를 하나 더 붙일 생각을 하면
&lt;code&gt;onResume&lt;/code&gt;과 &lt;code&gt;onDestroy&lt;/code&gt; 양쪽을 다 열어서 짝을 맞춰야 하는 거죠.
그리고 짝은 언젠가 어긋납니다. 하나만 적고 다른 하나를 잊는 식으로요.&lt;/p&gt;
&lt;h1 id=&quot;compose에는-자기만의-생명주기-장치가-있어요&quot;&gt;Compose에는 자기만의 생명주기 장치가 있어요&lt;/h1&gt;
&lt;p&gt;Compose는 View 시스템과 독립적인 메커니즘을 줍니다.
&lt;code&gt;DisposableEffect&lt;/code&gt;와 &lt;code&gt;LifecycleEventObserver&lt;/code&gt;요.&lt;/p&gt;
&lt;p&gt;이걸 쓰면 Activity가 지고 있던 책임을 UI 트리 안으로 내릴 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Composable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; AppLifecycleManager&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    updateGate: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;UpdateGate&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    billingGate: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;BillingGate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; lifecycleOwner &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; LocalLifecycleOwner.current&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; currentUpdateGate &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; rememberUpdatedState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(updateGate)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; currentBillingGate &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; rememberUpdatedState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(billingGate)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    DisposableEffect&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(lifecycleOwner) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;        val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; observer &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; LifecycleEventObserver&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { _, event &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;            when&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (event) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                Lifecycle.Event.ON_RESUME &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                    currentUpdateGate.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onResume&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                Lifecycle.Event.ON_DESTROY &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                    currentUpdateGate.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;onDestroy&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                    currentBillingGate.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;close&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;                }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;                else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; -&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        lifecycleOwner.lifecycle.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;addObserver&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(observer)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;        onDispose&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;            lifecycleOwner.lifecycle.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;removeObserver&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(observer)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;핵심은 &lt;code&gt;onDispose&lt;/code&gt;가 &lt;code&gt;addObserver&lt;/code&gt; 바로 옆에 있다는 것이에요.&lt;/p&gt;
&lt;p&gt;붙이는 코드와 떼는 코드가 한 함수 안에 나란히 있으면 짝을 잊기가 어렵습니다.
Activity 콜백에 나눠 적으면 두 함수가 수십 줄 떨어져 있고,
그 사이에 다른 것들이 끼어들면서 짝이 흐려져요.&lt;/p&gt;
&lt;p&gt;이건 &lt;a href=&quot;/design-system/same-size-comment-binds-nothing.html&quot;&gt;디자인 시스템 세우기 2&lt;/a&gt;와 같은 이야기입니다.
거기서는 “같아야 하는 값을 한 곳에 두고 가리킨다”였고,
여기서는 “짝지어야 하는 동작을 한 곳에 둔다”인 거죠.&lt;/p&gt;
&lt;h1 id=&quot;rememberupdatedstate가-여기-있는-이유&quot;&gt;&lt;code&gt;rememberUpdatedState&lt;/code&gt;가 여기 있는 이유&lt;/h1&gt;
&lt;p&gt;이 줄이 왜 필요한지가 처음엔 안 보였어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; currentUpdateGate &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; rememberUpdatedState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(updateGate)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;DisposableEffect&lt;/code&gt;의 키가 &lt;code&gt;lifecycleOwner&lt;/code&gt; 하나입니다.
그러니 &lt;code&gt;updateGate&lt;/code&gt;가 새 인스턴스로 바뀌어도 이 블록은 다시 안 돕니다.
그리고 안에 만들어둔 &lt;code&gt;observer&lt;/code&gt; 람다는 처음 조합 때의 &lt;code&gt;updateGate&lt;/code&gt;를 붙잡고 있어요.&lt;/p&gt;
&lt;p&gt;그러면 나중에 앱이 죽을 때 옛 게이트를 닫게 됩니다.
진짜 열려 있는 건 새 게이트인데요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rememberUpdatedState&lt;/code&gt;는 그 사이를 이어줍니다.
람다는 항상 &lt;code&gt;.value&lt;/code&gt;로 가장 최근 것을 읽고,
그렇다고 옵저버를 다시 붙이지도 않아요.&lt;/p&gt;
&lt;p&gt;여기가 재밌었어요. &lt;code&gt;DisposableEffect&lt;/code&gt;의 키를 &lt;code&gt;updateGate&lt;/code&gt;로 바꾸면
매번 다시 붙었다 떼었다 하게 되고, 그건 그것대로 낭비거든요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;다시 구독할 이유가 없는데 최신 값은 필요할 때 — 그 자리에 &lt;code&gt;rememberUpdatedState&lt;/code&gt;가 섭니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;그래서-mainactivity에-남은-것&quot;&gt;그래서 &lt;code&gt;MainActivity&lt;/code&gt;에 남은 것&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;onResume&lt;/code&gt;도 &lt;code&gt;onDestroy&lt;/code&gt;도 통째로 사라졌습니다.
&lt;code&gt;setContent { }&lt;/code&gt; 안에 한 줄만 남았어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;setContent&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    AppLifecycleManager&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(updateGate, billing)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MainActivity&lt;/code&gt;는 이름 그대로 화면을 띄우는 최초의 도화지로 돌아갔습니다.&lt;/p&gt;
&lt;p&gt;그리고 결제와 업데이트는 이제 자기 생명주기를 스스로 구독하고 해제해요.
누가 대신 챙겨주는 게 아니라요.&lt;/p&gt;
&lt;h2 id=&quot;작업을-끝내고-보니&quot;&gt;작업을 끝내고 보니&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Compose로 덮은 앱에서 Activity가 생명주기를 직접 관리하고 있으면,
그건 대개 아직 안 옮긴 것이지 필요해서 남은 게 아니에요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DisposableEffect&lt;/code&gt;의 값어치는 붙이는 코드와 떼는 코드가 붙어 있다는 것입니다.&lt;/li&gt;
&lt;li&gt;다시 구독할 이유는 없는데 최신 값이 필요하면 &lt;code&gt;rememberUpdatedState&lt;/code&gt;를 씁니다.
&lt;code&gt;DisposableEffect&lt;/code&gt;의 키에 넣어버리면 매번 다시 붙어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;p&gt;이 시리즈를 한 줄로 줄이면 이렇습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;빌드가 통과했다는 것은 아무것도 보증하지 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;1편에서 4편까지는 그걸 화면과 문장에서 봤어요.
슬롯을 비웠더니 남의 글꼴이 들어왔고, elevation을 올렸는데 삼각형은 안 보였고,
두 줄로 적은 문자열은 한 줄로 접혔고, 검증을 통과한 값이 다시 검증됐죠.
전부 컴파일러가 아무 말도 안 한 것들이었습니다.&lt;/p&gt;
&lt;p&gt;5편에서 8편까지는 구조였는데, 돌아보면 넷 다 같은 동작이었어요.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;흩어져 있던 것&lt;/th&gt;&lt;th&gt;모은 곳&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;5편&lt;/td&gt;&lt;td&gt;손으로 엮던 의존성&lt;/td&gt;&lt;td&gt;Hilt · Navigation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;6편&lt;/td&gt;&lt;td&gt;저장소 둘&lt;/td&gt;&lt;td&gt;&lt;code&gt;AppRepository&lt;/code&gt;의 한 줄기&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;7편&lt;/td&gt;&lt;td&gt;콜백 열세 개&lt;/td&gt;&lt;td&gt;&lt;code&gt;CalculatorAction&lt;/code&gt; 한 입구&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8편&lt;/td&gt;&lt;td&gt;Activity에 흩어진 생명주기&lt;/td&gt;&lt;td&gt;&lt;code&gt;AppLifecycleManager&lt;/code&gt; 한 자리&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;하나였던 것은 어긋날 수가 없습니다.
그리고 어긋날 수 없게 만드는 일이, 결국 매번 같은 답이었어요.&lt;/p&gt;
&lt;p&gt;이건 &lt;a href=&quot;/category/design-system/&quot;&gt;디자인 시스템 세우기&lt;/a&gt; 시리즈의 결론과도 정확히 같습니다.
거기서는 색과 치수였고 여기서는 의존성과 상태였을 뿐이에요.&lt;/p&gt;
&lt;p&gt;도구가 조용히 실패하는 이야기가 더 궁금하시면
&lt;a href=&quot;/category/silent-failures/&quot;&gt;조용히 실패하는 것들&lt;/a&gt; 시리즈로 이어집니다.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 7 — 화면이 콜백 열세 개를 들고 있었어요 - MVI로 입구를 하나로</title>
    <link href="https://blog.twinklelabs.kr/android/calculator-action-mvi.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/calculator-action-mvi.html</id>
    <published>2026-08-23T18:00:00.000Z</published>
    <updated>2026-08-23T18:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="compose"/>
    <category term="mvi"/>
    <category term="구조"/>
    <category term="리팩토링"/>
    <summary type="html">상태 끌어올리기를 하다 보니 화면 컴포저블의 시그니처가 끝없이 길어졌어요. 버튼 하나를 더할 때마다 화면·껍데기·ViewModel 세 곳을 고쳐야 했습니다. 사용자의 행동을 sealed interface 하나로 묶어 입구를 하나로 줄인 이야기예요.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 데이터를 한 줄기로 모은 이야기를 했어요.
저장소가 Room과 DataStore 둘인데 화면은 하나의 &lt;code&gt;Flow&lt;/code&gt;만 보게 만든 이야기였죠.&lt;/p&gt;
&lt;p&gt;그렇게 아래로 내려오는 길을 정리하고 나니, 이번엔 위로 올라가는 길이 눈에 띄었습니다.
화면에서 로직 층으로 올라가는 그 길이요.&lt;/p&gt;
&lt;h1 id=&quot;시그니처가-계속-길어지고-있었어요&quot;&gt;시그니처가 계속 길어지고 있었어요&lt;/h1&gt;
&lt;p&gt;Compose에서 상태 끌어올리기(State Hoisting)를 하다 보면
최상단 화면 컴포저블의 시그니처가 무한정 길어집니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CalculatorScreen&lt;/code&gt;이 정확히 그랬어요.
사용자가 계산기에서 할 수 있는 행동이 다양한데, 그걸 전부 개별 람다로 열어뒀거든요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Composable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; CalculatorScreen&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    state: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalcState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onMode: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalcKind&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onBuyInput: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;BuyInput&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onFocus: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Slot&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onPaste: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Slot&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onKey: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;KeypadKey&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onCloseKeypad: () &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Unit,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onPickStock: () &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Unit,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onSave: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Long&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onReset: () &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Unit,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // … 열세 개까지 갔습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이게 왜 문제였냐면, 버튼 하나를 더할 때마다 고칠 자리가 셋이었어요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;CalculatorScreen&lt;/code&gt;의 시그니처&lt;/li&gt;
&lt;li&gt;그걸 호출하는 껍데기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ViewModel&lt;/code&gt;의 함수와 그 매핑&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;한 줄 더하는 일이 세 파일을 여는 일이 되면, 사람은 그 일을 안 하게 됩니다.
이건 5편에서 &lt;code&gt;AppViewModel&lt;/code&gt;에 한 줄 더하는 게 무서워졌던 것과 정확히 같은 병이었어요.&lt;/p&gt;
&lt;h1 id=&quot;화면은-무슨-함수를-부를지를-알-필요가-없어요&quot;&gt;화면은 “무슨 함수를 부를지”를 알 필요가 없어요&lt;/h1&gt;
&lt;p&gt;여기서 한 가지를 다시 봤습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;onSave&lt;/code&gt;, &lt;code&gt;onReset&lt;/code&gt;, &lt;code&gt;onKey&lt;/code&gt;… 이 이름들은 전부 “무엇을 실행하라”는 말이에요.
그런데 화면이 정말 알아야 하는 건 그게 아니었습니다.&lt;/p&gt;
&lt;p&gt;화면이 아는 것은 “사용자가 방금 무엇을 했다”뿐이에요.
그걸 받아서 무슨 함수를 태울지는 로직 층의 일이고요.&lt;/p&gt;
&lt;p&gt;그래서 MVI(Model-View-Intent)의 아이디어를 빌려서,
사용자의 모든 행위를 &lt;code&gt;sealed interface&lt;/code&gt; 하나로 묶었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/**&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; * 계산기 화면에서 발생하는 모든 사용자 행동을 하나로 묶는다.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;sealed&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; interface&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; CalculatorAction&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; ChangeMode&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; mode: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalcKind&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; ChangeBuyInput&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; input: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;BuyInput&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Focus&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; slot: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Slot&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Paste&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; slot: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Slot&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; text: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; KeyPressed&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; key: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;KeypadKey&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; CloseKeypad&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; PickStock&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Save&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; now: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Long&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Reset&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 화면의 시그니처는 이렇게 됐어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Composable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; CalculatorScreen&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    state: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalcState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    result: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalcResult&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    errors: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Map&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Slot&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;InputError&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    stocks: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;List&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Stock&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    onAction: (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Unit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,   &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 열세 개가 하나로&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    modifier: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Modifier&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ViewModel도 마찬가지로 &lt;code&gt;public&lt;/code&gt; 메서드를 전부 닫고 입구를 하나만 열었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; onAction&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(action: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorAction&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    when&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (action) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;        is&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; CalculatorAction.ChangeMode &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; _calc.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;update&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { … }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;        is&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; CalculatorAction.ChangeBuyInput &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; _calc.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;update&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { … }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;        // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;when&lt;/code&gt;이 &lt;code&gt;sealed interface&lt;/code&gt;를 받으니 케이스를 빠뜨리면 컴파일이 안 됩니다.
액션을 새로 더하면 컴파일러가 “여기도 처리해야 한다”고 알려줘요.
전에는 람다를 하나 더 열어두고 연결을 잊어도 아무 말이 없었거든요.&lt;/p&gt;
&lt;p&gt;이게 4편의 결론과 같은 이야기입니다.
사람의 기억에 맡기던 것을 컴파일러에게 넘긴 것.&lt;/p&gt;
&lt;h1 id=&quot;saveval-now-long--액션이-시각을-들고-옵니다&quot;&gt;&lt;code&gt;Save(val now: Long)&lt;/code&gt; — 액션이 시각을 들고 옵니다&lt;/h1&gt;
&lt;p&gt;작은 부분인데 마음에 들어서 적어둘게요.&lt;/p&gt;
&lt;p&gt;저장 액션은 &lt;code&gt;data object&lt;/code&gt;가 아니라 &lt;code&gt;data class Save(val now: Long)&lt;/code&gt;입니다.
저장 시각을 화면이 넘겨줘요.&lt;/p&gt;
&lt;p&gt;ViewModel 안에서 &lt;code&gt;System.currentTimeMillis()&lt;/code&gt;를 부르면 편하죠.
그런데 그 순간 그 함수는 시험할 수 없게 됩니다.
같은 입력을 줘도 결과가 매번 달라지니까요.&lt;/p&gt;
&lt;p&gt;시각이 액션에 실려 들어오면 로직은 그냥 받은 값을 씁니다.
테스트에서는 원하는 시각을 넣으면 되고요.&lt;/p&gt;
&lt;p&gt;바깥 세상에서 오는 값은 인자로 받습니다. 안에서 꺼내오지 않고요.&lt;/p&gt;
&lt;h1 id=&quot;같이-한-것--껍데기에서-길-찾기를-떼어냈어요&quot;&gt;같이 한 것 — 껍데기에서 길 찾기를 떼어냈어요&lt;/h1&gt;
&lt;p&gt;같은 날 &lt;code&gt;AppShell.kt&lt;/code&gt;도 손봤습니다. 600줄 가까이 가 있었거든요.&lt;/p&gt;
&lt;p&gt;이 파일 안에 두 가지가 섞여 있었어요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스캐폴드와 오버레이 — 바텀바, 시트, 확인창 같은 껍데기&lt;/li&gt;
&lt;li&gt;라우팅 — 어느 화면이 어디로 가고, 어떤 애니메이션으로 가는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;둘은 같이 살 이유가 없었습니다.
라우팅을 &lt;code&gt;ui/navigation/AppNavigation.kt&lt;/code&gt;로 옮겼어요.
&lt;code&gt;NavGraphBuilder&lt;/code&gt;의 확장 함수 하나입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;internal&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; NavGraphBuilder&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;appGraph&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;AppData&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    calcViewModel: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;CalculatorViewModel&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    shell: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;ShellState&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    navController: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;NavController&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    activity: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Activity&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;?,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;지금은 &lt;code&gt;AppShell.kt&lt;/code&gt; 486줄, &lt;code&gt;AppNavigation.kt&lt;/code&gt; 171줄이에요.
합은 비슷한데, “화면이 어떻게 이어지는가”를 보고 싶을 때 여는 파일이 하나가 됐습니다.&lt;/p&gt;
&lt;p&gt;5편에서 &lt;code&gt;ShellState&lt;/code&gt;를 지우지 않고 역할만 줄였던 것과 같은 판단이었어요.
쓸모없어서 걷는 게 아니라, 맡을 것만 맡기는 것.&lt;/p&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;화면 컴포저블의 콜백이 열 개를 넘어가면, 그건 인자가 많은 게 아니라
화면이 로직을 너무 많이 알고 있다는 신호예요.&lt;/li&gt;
&lt;li&gt;콜백 이름이 &lt;code&gt;on&amp;#x3C;할 일&gt;&lt;/code&gt;이면 화면이 실행할 함수를 아는 거고,
&lt;code&gt;on&amp;#x3C;일어난 일&gt;&lt;/code&gt;이면 화면은 사실만 전달합니다. 후자여야 해요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sealed interface&lt;/code&gt; + &lt;code&gt;when&lt;/code&gt;은 연결을 잊는 실수를 컴파일 오류로 바꿉니다.&lt;/li&gt;
&lt;li&gt;바깥 세상의 값(시각, 난수)은 액션에 실어 받습니다. 안에서 꺼내면 시험할 수 없어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;p&gt;이번 글은 화면 층에서 책임을 덜어낸 이야기였어요.
그런데 같은 날, 그보다 더 오래 눌러앉아 있던 곳이 하나 더 있었습니다.&lt;/p&gt;
&lt;p&gt;앱을 만들면 누구나 하나씩 갖고 있는, 온갖 SDK의 진입점이 되어버린 그 클래스요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/main-activity-lifecycle-compose.html&quot;&gt;8 — MainActivity 다이어트 - 생명주기를 Compose 트리로 내렸어요&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 6 — 저장소가 둘인데 화면은 하나로 봅니다 - AppRepository와 SSOT</title>
    <link href="https://blog.twinklelabs.kr/android/app-repository-ssot.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/app-repository-ssot.html</id>
    <published>2026-08-23T17:00:00.000Z</published>
    <updated>2026-08-23T17:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="architecture"/>
    <category term="mvvm"/>
    <category term="room"/>
    <category term="datastore"/>
    <summary type="html">목록은 Room이, 설정값은 DataStore가 들고 있어요. 화면이 그 둘을 다 알아야 할 이유는 없었습니다. combine으로 하나의 Flow로 묶어 단일 진실 공급원을 세운 이야기, 그리고 옛 저장본과 맺은 약속에 대한 이야기예요.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 하루를 통째로 구조에 쓴 이야기를 했어요.
손으로 만든 DI를 Hilt로, 손으로 만든 화면 전환을 Navigation으로 옮긴 날이었죠.&lt;/p&gt;
&lt;p&gt;그런데 그날로 끝나지 않았습니다. 바로 다음 날 두 번째 물결이 왔어요.
그리고 이번 세 편은 전부 같은 동작입니다. 흩어진 것을 한 곳으로 모으는 일.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;6편 데이터를 한 줄기로 — 지금 이 글&lt;/li&gt;
&lt;li&gt;7편 이벤트를 한 입구로&lt;/li&gt;
&lt;li&gt;8편 생명주기를 한 자리로&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;먼저 데이터부터요.&lt;/p&gt;
&lt;h1 id=&quot;저장소가-둘이-된-이유&quot;&gt;저장소가 둘이 된 이유&lt;/h1&gt;
&lt;p&gt;이 앱은 서버가 없어요. 기록은 기기 안에만 있습니다.
그런데 그 “기기 안”이 한 곳이 아니에요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Room — 목록이 되는 것 (종목, 계산 이력)&lt;/li&gt;
&lt;li&gt;DataStore — 값 하나로 끝나는 것 (테마, 언어, 통화, 광고 제거 여부)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;처음부터 이랬던 건 아니에요. 한때는 전부 JSON 한 덩이였습니다.
그런데 그 구조는 기록 하나를 지우려고 덩이 전체를 다시 써야 했어요.
목록이 길어질수록 쓰기가 비싸졌고, 무엇보다
“이 기록만 바꾼다”를 표현할 방법이 없었습니다.&lt;/p&gt;
&lt;p&gt;그래서 목록은 Room으로 옮겼어요. 옮기고 나니 저장소가 둘이 됐고요.&lt;/p&gt;
&lt;h1 id=&quot;그런데-화면이-그걸-알아야-할까요&quot;&gt;그런데 화면이 그걸 알아야 할까요&lt;/h1&gt;
&lt;p&gt;여기서 갈림길이 하나 있었습니다.&lt;/p&gt;
&lt;p&gt;저장소가 둘이니까 화면도 둘을 구독하게 할 수 있어요.
종목 목록은 Room에서, 테마는 DataStore에서 각각 가져오는 식으로요.&lt;/p&gt;
&lt;p&gt;동작은 합니다. 그런데 그 순간 화면이 저장 기술을 알게 돼요.
“이건 Room에 있고 저건 DataStore에 있다”를 화면이 기억해야 하는 거죠.&lt;/p&gt;
&lt;p&gt;그리고 그건 언제든 바뀔 수 있는 사실입니다.
실제로 이미 한 번 바뀌었잖아요. JSON 한 덩이에서 둘로요.
다음에 또 바뀌면 그때마다 화면을 전부 고쳐야 합니다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;AppRepository&lt;/code&gt; 하나를 세우고, 저장소가 둘이라는 사실을 그 안에 가뒀어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Singleton&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; AppRepository&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; @Inject&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; constructor&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; store: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;DataStore&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Preferences&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; database: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;AppDatabase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    /** Room 의 목록과 DataStore 의 설정을 한 덩이로 합쳐 내보낸다 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Flow&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;AppData&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; combine&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        database.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;stockDao&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getAllStocks&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        database.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;historyDao&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getAllRecords&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        store.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    ) { stockEntities, recordEntities, preferences &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;-&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;        // … 하나의 AppData 로 조립합니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;화면 쪽은 이 한 줄기만 봅니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;StateFlow&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;AppData&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;?&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; repository.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;data&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;저장소가 둘인 줄 모릅니다. 알 필요가 없고요.
셋이 되든 하나로 합쳐지든 이 줄은 안 바뀝니다.&lt;/p&gt;
&lt;h1 id=&quot;combine이-하는-일이-생각보다-커요&quot;&gt;&lt;code&gt;combine&lt;/code&gt;이 하는 일이 생각보다 커요&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;combine&lt;/code&gt;은 세 흐름 중 어느 하나라도 바뀌면 새 &lt;code&gt;AppData&lt;/code&gt;를 내보냅니다.&lt;/p&gt;
&lt;p&gt;이게 왜 좋냐면, 화면이 “언제 다시 그려야 하는가”를 따질 필요가 없어요.
종목을 지우든 테마를 바꾸든 결과는 똑같이 “새 &lt;code&gt;AppData&lt;/code&gt;가 왔다”입니다.&lt;/p&gt;
&lt;p&gt;전에는 이런 게 손으로 엮여 있었어요.
“설정이 바뀌었으니 이 화면도 갱신해야 하고…” 같은 연결이요.
그 연결은 새 화면이 생길 때마다 하나씩 늘어나고, 그중 하나는 반드시 빠집니다.&lt;/p&gt;
&lt;p&gt;단일 진실 공급원(Single Source of Truth)의 값어치가 여기 있었어요.
진실이 하나면 동기화할 것이 없습니다. 동기화가 없으면 어긋날 자리도 없고요.&lt;/p&gt;
&lt;p&gt;이건 &lt;a href=&quot;/design-system/light-dark-one-copy.html&quot;&gt;디자인 시스템 세우기 1&lt;/a&gt;에서
색 표를 한 벌로 만든 것과 정확히 같은 이야기예요.
거기서는 CSS 값이었고 여기서는 앱 상태일 뿐입니다.&lt;/p&gt;
&lt;h1 id=&quot;옛-저장본과-맺은-약속은-이름이-바뀌어도-안-바뀝니다&quot;&gt;옛 저장본과 맺은 약속은 이름이 바뀌어도 안 바뀝니다&lt;/h1&gt;
&lt;p&gt;이 파일에서 제일 조심스러웠던 게 이거예요.&lt;/p&gt;
&lt;p&gt;DataStore의 키 하나가 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; PROMO_UNLOCKED &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; booleanPreferencesKey&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;dev_unlocked&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;상수 이름은 &lt;code&gt;PROMO_UNLOCKED&lt;/code&gt;인데 저장된 키는 &lt;code&gt;dev_unlocked&lt;/code&gt;예요. 안 맞죠.&lt;/p&gt;
&lt;p&gt;화면에서 부르는 말이 바뀌었거든요.
그런데 저장된 키를 같이 바꾸면 어떻게 될까요?&lt;/p&gt;
&lt;p&gt;이미 코드를 넣어둔 기기에서 그 값이 사라집니다.
새 키에는 아무것도 없으니까요.
켜뒀던 것이 앱을 올리자 꺼지는 셈이고, 그 사람은 무엇이 없어졌는지도 모릅니다.&lt;/p&gt;
&lt;p&gt;그래서 이걸 법으로 적어뒀어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;저장된 이름은 사람이 볼 것이 아니라 옛 저장본과 맺은 약속이다.
부르는 말이 바뀌었다고 따라 바꾸지 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;같은 이유로 JSON 파서에도 이 옵션이 켜져 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; json &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; Json&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    ignoreUnknownKeys &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; // 앞으로 필드가 늘어도 옛 저장본을 버리지 않는다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    encodeDefaults &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모르는 필드를 만나면 터지는 게 아니라 그냥 넘어가요.
안 그러면 필드 하나 더한 날, 옛 저장본을 가진 사람의 기록이 통째로 안 읽힙니다.&lt;/p&gt;
&lt;h1 id=&quot;옮기는-일은-두-번-불려도-괜찮아야-해요&quot;&gt;옮기는 일은 두 번 불려도 괜찮아야 해요&lt;/h1&gt;
&lt;p&gt;JSON 한 덩이를 Room으로 옮기는 함수가 하나 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;suspend&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; migrateDataStoreToRoomIfNeeded&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; legacyJson &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; store.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;first&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()[APP_DATA] ?: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;return&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; legacy &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; runCatching&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { json.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;decodeFromString&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;AppData&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&gt;(legacyJson) }.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getOrNull&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;() ?: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;return&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // 이미 Room 에 무언가 있으면 덮지 않는다 — 두 번 불려도 기록이 겹치지 않는다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 챙긴 게 둘이에요.&lt;/p&gt;
&lt;p&gt;하나. 두 번 불려도 같은 결과여야 합니다.
앱이 언제 죽을지 모르고, 옮기는 도중에 죽을 수도 있어요.
다시 켰을 때 또 부르면 기록이 두 벌이 되면 안 됩니다.&lt;/p&gt;
&lt;p&gt;둘. 못 읽으면 조용히 포기합니다.
&lt;code&gt;runCatching { … }.getOrNull() ?: return&lt;/code&gt;이요.
옛 덩이가 깨져 있으면 앱을 죽이는 게 아니라 그냥 안 옮기는 쪽을 골랐어요.&lt;/p&gt;
&lt;p&gt;여기서 &lt;a href=&quot;/category/silent-failures/&quot;&gt;조용히 실패하는 것들&lt;/a&gt; 시리즈의 교훈과 부딪히는 것 같지만,
방향이 다릅니다. 그쪽은 도구가 결과를 안 남기고도 성공했다고 말한 경우였어요.
이건 실패해도 사용자가 잃을 게 없는 자리입니다. 옛 덩이는 그대로 남아 있으니까요.&lt;/p&gt;
&lt;h1 id=&quot;viewmodel은-저장-방법을-모릅니다&quot;&gt;ViewModel은 저장 방법을 모릅니다&lt;/h1&gt;
&lt;p&gt;5편에서 &lt;code&gt;AppViewModel&lt;/code&gt; 하나를 다섯으로 갈랐다고 했잖아요.
그 다섯이 전부 이 저장소 하나를 봅니다.&lt;/p&gt;
&lt;p&gt;그리고 화면은 이렇게만 부릅니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;viewModel.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;deleteRecord&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(id)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB에서 어떻게 지우는지, 예외를 어떻게 처리하는지는 화면이 모릅니다.
아는 것이 적을수록 고칠 일이 적어져요.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;저장 기술이 여럿이면 그걸 한 곳에 가둡니다. 화면이 알 이유가 없어요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;combine&lt;/code&gt;으로 한 줄기를 만들면 동기화할 것이 사라집니다. 진실이 하나니까요.&lt;/li&gt;
&lt;li&gt;저장된 키 이름은 옛 저장본과 맺은 약속입니다. 화면 문구가 바뀌어도 안 바꿔요.&lt;/li&gt;
&lt;li&gt;마이그레이션은 두 번 불려도 같은 결과여야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;작업을-끝내고-보니&quot;&gt;작업을 끝내고 보니&lt;/h2&gt;
&lt;p&gt;이번 글은 데이터를 한 줄기로 모은 이야기였어요.
그런데 데이터가 아래로 잘 흐르게 되니까, 이번엔 위로 올라가는 쪽이 눈에 띄더라고요.&lt;/p&gt;
&lt;p&gt;화면 컴포저블 하나가 콜백을 열세 개나 들고 있었습니다.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/calculator-action-mvi.html&quot;&gt;7 — 화면이 콜백 열세 개를 들고 있었어요 - MVI로 입구를 하나로&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>디자인 시스템 세우기 3 — Aurora Ledger - CSS 주석 한 줄에서 태어났어요</title>
    <link href="https://blog.twinklelabs.kr/design-system/aurora-ledger.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/design-system/aurora-ledger.html</id>
    <published>2026-08-23T16:00:00.000Z</published>
    <updated>2026-08-23T16:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Design System"/>
    <category term="design-system"/>
    <category term="브랜딩"/>
    <category term="토큰"/>
    <summary type="html">이름의 유래는 어디에도 적힌 적이 없었어요. 목업을 다시 짜던 날 CSS 주석 한 줄로 태어났고, 2주 뒤 모든 제품의 기준이 됐습니다. 그 사이에 되돌린 결정이 셋 있어요.</summary>
    <content type="html">&lt;p&gt;지난 두 편에서는 한 벌로 두는 법을 이야기했어요.
색은 &lt;code&gt;light-dark()&lt;/code&gt; 한 줄로, 치수는 주석이 아니라 이름 하나로요.&lt;/p&gt;
&lt;p&gt;마지막 편인 이번 글에서는 그렇게 자란 시스템 전체를 소개할게요.
이름이 어디서 왔는지, 사다리가 몇 칸인지, 그리고 되돌린 결정 셋에 대한 이야기입니다.&lt;/p&gt;
&lt;p&gt;먼저 시작이 좀 특이했어요.
디자인 시스템은 보통 “만들자”고 마음먹고 만들잖아요. 이건 그렇게 시작하지 않았습니다.&lt;/p&gt;
&lt;p&gt;2026년 8월 9일, 물타기 계산기의 목업을 다시 짜던 커밋에 주석 한 줄이 있었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* Visual refresh · Aurora Ledger */&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그때 목업에는 스킨이 여러 벌 있었고, 그중 보라 한 벌의 이름이었습니다.
그리고 2주 뒤에 그 이름이 모든 제품의 기준이 됐어요.&lt;/p&gt;
&lt;h1 id=&quot;이름을-나중에-해석했어요&quot;&gt;이름을 나중에 해석했어요&lt;/h1&gt;
&lt;p&gt;유래가 적힌 적이 없어서, 8월 23일에 이름을 뜯어 읽어봤습니다.
정한 사람이 다른 이유를 기억하면 그쪽이 맞아요. 아래는 해석입니다.&lt;/p&gt;
&lt;h2 id=&quot;aurora--오로라&quot;&gt;Aurora — 오로라&lt;/h2&gt;
&lt;p&gt;이 시스템의 얼굴은 세 가지예요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“보라 → 밝은 보라”로 흐르는 그라데이션&lt;/li&gt;
&lt;li&gt;남보라를 머금은 밤하늘 중성색 (Dusk)&lt;/li&gt;
&lt;li&gt;별하늘 장식&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;밤하늘의 빛 이름이 그 얼굴에 맞더라고요.
그리고 모토인 “Shine by Yourself — 스스로 빛난다”와 같은 방향을 봅니다.&lt;/p&gt;
&lt;h2 id=&quot;ledger--장부&quot;&gt;Ledger — 장부&lt;/h2&gt;
&lt;p&gt;첫 제품이 물타기 계산기예요. 종목을 줄줄이 적는 매매 장부죠.
이 시스템이 “숫자를 읽는 화면”을 위한 것이라는 뜻으로 붙었습니다.&lt;/p&gt;
&lt;p&gt;지금도 코드와 문서가 종목 줄을 “장부 줄”이라고 불러요.&lt;/p&gt;
&lt;h2 id=&quot;줄여-부르지-않습니다&quot;&gt;줄여 부르지 않습니다&lt;/h2&gt;
&lt;p&gt;같은 이름 &lt;code&gt;Aurora&lt;/code&gt;를 음악 쪽에서도 쓰고 있어요.
그러니 “Aurora를 입는다”가 어느 쪽인지는 문맥으로만 갈립니다.&lt;/p&gt;
&lt;p&gt;그래서 디자인 시스템은 “Aurora Ledger” 한 벌로 불러요.
앱의 코드 식별자도 같은 이유로 &lt;code&gt;Aurora*&lt;/code&gt;가 아니라 &lt;code&gt;Twinkle*&lt;/code&gt;입니다(&lt;code&gt;TwinkleTheme&lt;/code&gt;).&lt;/p&gt;
&lt;h1 id=&quot;토큰을-두-층으로-짭니다&quot;&gt;토큰을 두 층으로 짭니다&lt;/h1&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;1층  원색 사다리 (--aurora-*)   ← 화면은 이 이름을 부르지 않습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2층  의미 토큰   (--bg, --text, --accent …)  ← 화면은 오직 이것만&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;덕분에 라이트/다크는 2층만 갈아 끼우면 되고, 원색이 화면에 새지 않아요.&lt;/p&gt;
&lt;p&gt;화면 코드에 &lt;code&gt;#7040D9&lt;/code&gt;가 한 번이라도 적히면 그 자리는 테마를 따라가지 못합니다.
그리고 그런 자리는 한 번 생기면 잘 안 없어져요.&lt;/p&gt;
&lt;h1 id=&quot;사다리들&quot;&gt;사다리들&lt;/h1&gt;
&lt;p&gt;값의 정본은 저장소의 원칙 문서에 있습니다. 여기엔 사다리의 모양만 옮길게요.&lt;/p&gt;
&lt;h2 id=&quot;불투명도--여섯-칸&quot;&gt;불투명도 — 여섯 칸&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;strong&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;soft&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;half&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;ring&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;tint&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;wash&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;.88&lt;/td&gt;&lt;td&gt;.70&lt;/td&gt;&lt;td&gt;.50&lt;/td&gt;&lt;td&gt;.35&lt;/td&gt;&lt;td&gt;.20&lt;/td&gt;&lt;td&gt;.08&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;“옅은 면”만 라이트 8% · 다크 12%로 갈립니다.
“옅게”는 다크를 보고 하는 말이기 때문이에요.
같은 값을 주면 흰 바탕에서 장식이 먼저 사라지거든요.&lt;/p&gt;
&lt;h2 id=&quot;간격--8pt&quot;&gt;간격 — 8pt&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2 · 4 · 6 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;글자--열-칸&quot;&gt;글자 — 열 칸&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;11 · 12 · 14 · 16 · 20 · 24 · 28 · 32 · 36 · 40&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그 위 세 칸(48 · 60 · 72)은 첫 화면 전용이에요.
히어로와 큰 숫자만 오르고 본문으로 안 내려옵니다.&lt;/p&gt;
&lt;p&gt;굵기는 가진 넷(400 · 500 · 600 · 700)만 씁니다.
안 실은 굵기(550 · 650)를 부르면 브라우저가 흉내 내거나 이웃 칸으로 스냅해요.
“중간쯤”이라고 적어둔 자리가 실제로는 아무 데도 중간이 아니게 됩니다.&lt;/p&gt;
&lt;h2 id=&quot;모양&quot;&gt;모양&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;11&lt;/th&gt;&lt;th&gt;14&lt;/th&gt;&lt;th&gt;18&lt;/th&gt;&lt;th&gt;24&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;입력칸&lt;/td&gt;&lt;td&gt;안쪽 패널·키&lt;/td&gt;&lt;td&gt;카드&lt;/td&gt;&lt;td&gt;시트·모달&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;알약(999)은 배지·버튼처럼 한 줄이 통째로 둥근 것에만 씁니다.
카드는 여기 오지 않아요.&lt;/p&gt;
&lt;h2 id=&quot;움직임--곡선-둘&quot;&gt;움직임 — 곡선 둘&lt;/h2&gt;
&lt;div class=&quot;table-wrap&quot;&gt;














&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;fast&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;base&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;slow&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;150ms&lt;/td&gt;&lt;td&gt;250ms&lt;/td&gt;&lt;td&gt;350ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;곡선은 둘입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;들어오고 움직이는 것 — &lt;code&gt;cubic-bezier(.25, 1, .5, 1)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;나가는 것 — &lt;code&gt;cubic-bezier(.4, 0, 1, 1)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이름을 &lt;code&gt;easeIn&lt;/code&gt;/&lt;code&gt;easeOut&lt;/code&gt;으로 짓지 않았어요.
CSS에서 그 말은 뜻이 뒤집혀 있어서(&lt;code&gt;ease-out&lt;/code&gt;이 감속) 반드시 한 번은 잘못 읽히거든요.
쓰임으로 부릅니다.&lt;/p&gt;
&lt;h1 id=&quot;되돌린-결정-셋&quot;&gt;되돌린 결정 셋&lt;/h1&gt;
&lt;p&gt;되돌린 결정은 지우지 않아요.
지우면 다음 사람(대개 미래의 나)이 같은 자리를 두 번 팝니다.&lt;/p&gt;
&lt;h2 id=&quot;하나--중성색을-순수-회색으로&quot;&gt;하나 — 중성색을 순수 회색으로&lt;/h2&gt;
&lt;p&gt;한 번 걷어봤어요. 그리고 되돌렸습니다.&lt;/p&gt;
&lt;p&gt;순수 회색 화면은 브랜드의 공기가 빠져 누가 만든 화면인지 말하지 못하더라고요.&lt;/p&gt;
&lt;p&gt;그래서 남보라를 한 방울만 머금습니다. 흠뻑 물들이지는 않아요.
중성색의 채도는 낮게 눌러두고, 채도 있는 보라는
“지금 누를 것”과 “지금 손이 가 있는 곳”에만 세웁니다.&lt;/p&gt;
&lt;h2 id=&quot;둘--등락색을-보라-하나로&quot;&gt;둘 — 등락색을 보라 하나로&lt;/h2&gt;
&lt;p&gt;오름/내림을 보라 하나로 통일해봤어요. 그리고 되돌렸습니다.&lt;/p&gt;
&lt;p&gt;걷었던 이유는 “같은 자리의 색이 매번 바뀌면
이 색이 좋은 소식인지를 먼저 배워야 한다”였어요.
그런데 한 화면에서 오르내리는 값이 하나뿐이면 배울 것이 없더라고요.&lt;/p&gt;
&lt;p&gt;지금은 한국 시장 문법을 따릅니다. 오름 빨강, 내림 파랑.&lt;/p&gt;
&lt;p&gt;색이 방향을 말해도 말과 화살표는 그대로 둬요(▲▼ · “낮아졌어요”).
색맹인 분들께는 색이 없는 것과 같고,
그 문법은 나라마다 반대라 색 하나만 믿으면 영어로 열었을 때 거짓말이 되니까요.&lt;/p&gt;
&lt;p&gt;그리고 등락색은 위험색과 다른 토큰입니다.
값이 오른 것과 되돌릴 수 없는 것은 색이 겹쳐도 뜻이 달라요.
한 토큰으로 묶으면 다음 사람이 “평단가가 올랐다”를 “위험하다”로 읽습니다.&lt;/p&gt;
&lt;h2 id=&quot;셋--화려함의-방향이-뒤집혔어요&quot;&gt;셋 — 화려함의 방향이 뒤집혔어요&lt;/h2&gt;
&lt;p&gt;예전 법은 이랬습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;큰 숫자에 그라데이션. “다음” 버튼에는 금지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이게 뒤집혔어요. 지금은 반대입니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;그라데이션은 누를 것에만. 큰 숫자는 accent 단색.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;글자에 그라데이션을 입히면 값이 장식으로 읽히더라고요.
화려함은 “읽을 것”이 아니라 “누를 것”의 신호였습니다.&lt;/p&gt;
&lt;p&gt;다만 한 화면에 화려한 면은 하나예요.
키패드의 “다음/완료”와 빈 상태의 CTA에만 입힙니다.&lt;/p&gt;
&lt;p&gt;전폭 저장 버튼은 단색 primary예요.
이미 충분히 크고, 거기까지 흐르게 하면 어느 쪽이 지금 할 일인지 흐려지거든요.&lt;/p&gt;
&lt;h1 id=&quot;어디까지-입었나&quot;&gt;어디까지 입었나&lt;/h1&gt;
&lt;div class=&quot;table-wrap&quot;&gt;




























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;제품&lt;/th&gt;&lt;th&gt;무엇&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;홈&lt;/td&gt;&lt;td&gt;웹&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;디자인 시스템&lt;/td&gt;&lt;td&gt;웹&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;블로그&lt;/td&gt;&lt;td&gt;웹&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;약관&lt;/td&gt;&lt;td&gt;웹&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;물타기 계산기&lt;/td&gt;&lt;td&gt;앱&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;웹 넷에 앱 하나입니다.&lt;/p&gt;
&lt;p&gt;같은 사다리가 두 벌의 코드로 서 있다는 뜻이기도 해요.
웹에서는 CSS 변수, 앱에서는 Kotlin 토큰. 값은 하나고 부르는 이름만 다릅니다.&lt;/p&gt;
&lt;p&gt;그리고 그 “부르는 이름만 다르다”를 지키는 게 생각보다 일이에요.
한쪽에 칸을 더하고 다른 쪽을 잊으면,
그때부터 두 벌은 서서히 다른 시스템이 되거든요.
이 시리즈의 앞 두 편이 바로 그 이야기였습니다.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;p&gt;이름이 CSS 주석에서 태어난 게 우연 같지만, 돌아보면 그 순서가 맞았던 것 같아요.&lt;/p&gt;
&lt;p&gt;“디자인 시스템을 만들자”로 시작하면 쓸 일 없는 칸부터 채우게 됩니다.
그런데 목업 한 장을 제대로 그리고 나서 “이 값들에 이름을 붙이자”로 가면,
사다리의 칸 수가 실제로 필요했던 만큼으로 정해져요.&lt;/p&gt;
&lt;p&gt;아이콘 사다리가 &lt;code&gt;12 · 16 · 20 · 26&lt;/code&gt; 네 칸뿐인 이유도 그래서입니다.
가운데에 24가 없는 게 이상해 보이지만, 아직 24짜리 아이콘이 필요한 적이 없었거든요.
빈 칸을 미리 채워두면 그건 사다리가 아니라 그냥 목록이 됩니다.&lt;/p&gt;
&lt;p&gt;세 편을 한 줄로 줄이면 이래요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;한 벌은 한 번만 적고, 나머지는 전부 그 하나를 가리키게 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;색이든, 치수든, 시스템 전체든 같은 법이었습니다.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 5 — 하루에 구조를 세 번 바꿨어요 - Hilt · Navigation · 걷어낸 것들</title>
    <link href="https://blog.twinklelabs.kr/android/hilt-navigation-cleanup.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/hilt-navigation-cleanup.html</id>
    <published>2026-08-23T13:30:00.000Z</published>
    <updated>2026-08-23T13:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="compose"/>
    <category term="hilt"/>
    <category term="navigation"/>
    <category term="리팩토링"/>
    <summary type="html">돌아가는 앱을 스토어에 올리고 나서, 하루를 통째로 구조에 썼어요. 손으로 만든 DI를 Hilt로, 손으로 만든 화면 전환을 Navigation으로, 그리고 이미 낡은 것들을 걷어냈습니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 버그 둘을 고치다가 구조를 바꾼 이야기를 했어요.
값을 맞추는 데서 멈추면 셋째 버그가 오니까, 검증된 값이 타입으로 증명을 들고 다니게 만든 이야기였죠.&lt;/p&gt;
&lt;p&gt;이 시리즈의 마지막인 이번 글은 그 연장선입니다.
다만 규모가 훨씬 커요. 하루를 통째로 구조에만 썼거든요.&lt;/p&gt;
&lt;p&gt;앱은 이미 돌아가고 있었습니다. 테스트 트랙에 올라가 있었고, 기능도 다 됐어요.
그런데 안을 열면 “처음 만들면서 손에 잡히는 대로 붙인 것”이 그대로 있었습니다.&lt;/p&gt;
&lt;p&gt;크게 세 덩이였어요.&lt;/p&gt;
&lt;h1 id=&quot;하나--손으로-만든-di를-hilt로&quot;&gt;하나 — 손으로 만든 DI를 Hilt로&lt;/h1&gt;
&lt;h2 id=&quot;무엇이-문제였나&quot;&gt;무엇이 문제였나&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;AppRepository&lt;/code&gt;와 &lt;code&gt;AppViewModel&lt;/code&gt;을 직접 만들어서 직접 넘기고 있었어요.
처음엔 이게 제일 간단합니다. 의존성이 셋일 때는 정말 그래요.&lt;/p&gt;
&lt;p&gt;그런데 Room DB가 들어오고, DataStore가 들어오고, 화면이 늘면서
“이걸 만들려면 저걸 먼저 만들어야 하고” 하는 사슬이 길어졌습니다.
그리고 그 사슬을 사람이 손으로 엮고 있었어요.&lt;/p&gt;
&lt;h2 id=&quot;무엇을-했나&quot;&gt;무엇을 했나&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;DatabaseModule&lt;/code&gt; 하나를 두고 Room DB와 DataStore를 싱글톤으로 제공했습니다.
나머지는 전부 생성자 주입으로 바꿨어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@HiltViewModel&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; CalculatorViewModel&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; @Inject&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; constructor&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; repository: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;StockRepository&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;ViewModel&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;얻은-것&quot;&gt;얻은 것&lt;/h2&gt;
&lt;p&gt;보일러플레이트가 준 것보다,
“이 클래스가 무엇에 기대는가”가 생성자 한 줄에 다 적히게 된 것이 컸어요.
전에는 그 답을 알려면 만드는 자리를 찾아가야 했거든요.&lt;/p&gt;
&lt;h1 id=&quot;둘--비대한-viewmodel을-다섯으로-나눴어요&quot;&gt;둘 — 비대한 ViewModel을 다섯으로 나눴어요&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;AppViewModel&lt;/code&gt; 하나가 계산·이력·설정·종목 관리를 전부 들고 있었습니다.
이름부터가 “앱”이었어요. 앱이 하는 일을 다 하는 클래스라는 뜻이죠.&lt;/p&gt;
&lt;p&gt;다섯으로 갈랐습니다.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;




























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;ViewModel&lt;/th&gt;&lt;th&gt;맡는 것&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;CalculatorViewModel&lt;/code&gt;&lt;/td&gt;&lt;td&gt;계산&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;HistoryViewModel&lt;/code&gt;&lt;/td&gt;&lt;td&gt;이력&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;SettingsViewModel&lt;/code&gt;&lt;/td&gt;&lt;td&gt;설정&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;StockViewModel&lt;/code&gt;&lt;/td&gt;&lt;td&gt;종목 관리&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;MainViewModel&lt;/code&gt;&lt;/td&gt;&lt;td&gt;앱 껍데기&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;화면 단위가 아니라 도메인 단위로 갈랐어요.
화면은 합쳐지거나 나뉘는데 도메인은 잘 안 그러거든요.&lt;/p&gt;
&lt;h2 id=&quot;상태를-파일-밖으로-꺼냈어요&quot;&gt;상태를 파일 밖으로 꺼냈어요&lt;/h2&gt;
&lt;p&gt;가르고 나서 한 가지가 더 필요했습니다.
ViewModel 안에 있던 &lt;code&gt;data class&lt;/code&gt; 상태들을 &lt;code&gt;CalculatorState.kt&lt;/code&gt;로 따로 꺼냈어요.&lt;/p&gt;
&lt;p&gt;안 꺼내면 A의 상태를 B가 쓰려고 A를 import하게 되고,
그 순간 가른 의미가 없어집니다.
상태가 독립된 파일에 있으면 상호 참조가 생길 자리가 없어요.&lt;/p&gt;
&lt;h1 id=&quot;셋--화면-전환을-navigation으로&quot;&gt;셋 — 화면 전환을 Navigation으로&lt;/h1&gt;
&lt;h2 id=&quot;손으로-만든-백스택&quot;&gt;손으로 만든 백스택&lt;/h2&gt;
&lt;p&gt;화면 전환을 &lt;code&gt;ShellState&lt;/code&gt;라는 상태 객체가 관리하고 있었어요.
어느 화면인지, 뒤로 가면 어디로 가는지를 전부 손으로 적었습니다.&lt;/p&gt;
&lt;h2 id=&quot;type-safe-navigation&quot;&gt;Type-safe Navigation&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Routes.kt&lt;/code&gt;에 &lt;code&gt;@Serializable&lt;/code&gt; 클래스를 두고, 경로를 객체로 넘기게 했어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Serializable&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Calculator&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;@Serializable&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; StockDetail&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; stockId: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Long&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;navController.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;navigate&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;StockDetail&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(stockId &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;문자열 경로의 오타 위험이 사라지고, 인자가 컴파일 타임에 검사됩니다.&lt;/p&gt;
&lt;h2 id=&quot;애니메이션-이식이-제일-까다로웠어요&quot;&gt;애니메이션 이식이 제일 까다로웠어요&lt;/h2&gt;
&lt;p&gt;기존에 &lt;code&gt;AnimatedContent&lt;/code&gt;로 만든 연출이 있었습니다.
화면마다 깊이(depth)를 매기고, 깊은 데로 가면 왼쪽에서,
얕은 데로 가면 오른쪽에서 미끄러지는 방식이에요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NavHost&lt;/code&gt;에 그대로 옮기려면 “지금 어디서 어디로 가는가”를 알아야 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;enterTransition &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; from &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; initialState.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;toRoute&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;…&gt;().depth&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; to &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; targetState.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;toRoute&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;&amp;#x3C;…&gt;().depth&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    slideIn&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(direction &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (to &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; from) Left &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Right)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;initialState&lt;/code&gt;와 &lt;code&gt;targetState&lt;/code&gt;에서 각각 라우트를 꺼내 깊이를 비교해요.
이게 되니까 표준 내비게이션을 쓰면서 연출은 그대로 유지할 수 있었습니다.&lt;/p&gt;
&lt;h2 id=&quot;shellstate는-살아남았어요--역할만-줄어서&quot;&gt;&lt;code&gt;ShellState&lt;/code&gt;는 살아남았어요 — 역할만 줄어서&lt;/h2&gt;
&lt;p&gt;전부 걷어낼 뻔했는데, 다이얼로그·오버레이·필터 같은
화면이 아닌 UI 보조 상태는 여전히 어딘가 있어야 했습니다.&lt;/p&gt;
&lt;p&gt;내비게이션 상태만 &lt;code&gt;NavController&lt;/code&gt;로 넘기고, &lt;code&gt;ShellState&lt;/code&gt;는 나머지를 맡게 정제했어요.
“쓸모없어졌으니 지운다”가 아니라 “맡을 것만 맡긴다”가 맞았습니다.&lt;/p&gt;
&lt;h1 id=&quot;넷--이미-낡은-것들을-걷었어요&quot;&gt;넷 — 이미 낡은 것들을 걷었어요&lt;/h1&gt;
&lt;h2 id=&quot;appcompatactivity--componentactivity&quot;&gt;&lt;code&gt;AppCompatActivity&lt;/code&gt; → &lt;code&gt;ComponentActivity&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;완전히 Compose로 만든 앱인데 구형 &lt;code&gt;AppCompatActivity&lt;/code&gt;를 상속하고 있었어요.
쓰지도 않는 호환성 레이어를 들고 다닌 셈이죠.&lt;/p&gt;
&lt;p&gt;같이 걷힌 게 하나 더 있습니다.
&lt;code&gt;AppCompatDelegate&lt;/code&gt;와 &lt;code&gt;UiModeManager&lt;/code&gt;로 하던 수동 테마 설정이요.
Compose 테마 시스템이 이미 그 일을 하고 있었으므로 두 벌이었어요.&lt;/p&gt;
&lt;h2 id=&quot;린트가-잡아준-것&quot;&gt;린트가 잡아준 것&lt;/h2&gt;
&lt;p&gt;경고로 치부하기 쉬운 것들이 실제로 사용자가 보는 것이었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TypographyQuotes&lt;/code&gt; — 프랑스어·이탈리아어 리소스에서 일반 아포스트로피(&lt;code&gt;'&lt;/code&gt;)를 쓰고 있었어요.
그 언어권의 조판 관례는 스마트 아포스트로피(&lt;code&gt;’&lt;/code&gt;)입니다. 전수 교체했어요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ObsoleteSdkInt&lt;/code&gt; — 이제 안 오는 SDK 버전을 분기하고 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;린트 경고를 “나중에”로 미루면 쌓이고, 쌓이면 진짜 경고가 그 속에 묻혀요.&lt;/p&gt;
&lt;h2 id=&quot;targetsdk-37&quot;&gt;&lt;code&gt;targetSdk&lt;/code&gt; 37&lt;/h2&gt;
&lt;p&gt;올리고 나면 동작이 바뀌는 것들이 있어서 한 번 다 돌려봐야 합니다.
출시 전날 할 일이 아니에요. 이런 날에 합니다.&lt;/p&gt;
&lt;h1 id=&quot;하루를-이렇게-쓴-이유&quot;&gt;하루를 이렇게 쓴 이유&lt;/h1&gt;
&lt;p&gt;기능은 이미 다 됐는데 왜 하루를 구조에 썼을까요?&lt;/p&gt;
&lt;p&gt;다음 기능을 붙일 자리가 없어서였어요.
&lt;code&gt;AppViewModel&lt;/code&gt;에 한 줄을 더하는 일이 점점 무서워지고 있었습니다.
무서워지면 안 붙이게 되고, 안 붙이면 앱이 거기서 멈춰요.&lt;/p&gt;
&lt;p&gt;그리고 이런 일은 “나중에”가 안 옵니다.
기능이 늘수록 옮길 것이 늘어서 미룬 만큼 비싸지거든요.
지금이 제일 싼 시점이었어요.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;갈라놓은 ViewModel의 비즈니스 로직에 단위 테스트를 붙이는 일.
가른 이유의 절반이 “시험할 수 있게”였는데 아직 시험은 안 붙었습니다.&lt;/li&gt;
&lt;li&gt;딥링크. 표준 내비게이션으로 옮겨서 이제 붙이기 쉬워졌어요. 필요해지면 할 겁니다.&lt;/li&gt;
&lt;li&gt;런처 아이콘이 영역을 가득 채운다는 린트 경고(&lt;code&gt;IconLauncherShape&lt;/code&gt;).
이건 디자인 쪽 일이에요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;p&gt;이날 정리한 건 “이 클래스가 무엇에 기대는가”였어요.
의존성을 Hilt에게 맡기고, 화면 전환을 Navigation에게 맡긴 거죠.&lt;/p&gt;
&lt;p&gt;그런데 하루로 안 끝났습니다. 바로 다음 날 두 번째 물결이 왔어요.
이번엔 “무엇이 어디로 흐르는가” 차례였습니다.&lt;/p&gt;
&lt;p&gt;다음 세 편이 그 이야기예요. 데이터, 이벤트, 생명주기 순으로요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/app-repository-ssot.html&quot;&gt;6 — 저장소가 둘인데 화면은 하나로 봅니다 - AppRepository와 SSOT&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>디자인 시스템 세우기 2 — 저쪽과 같은 크기라는 주석은 아무것도 묶지 않습니다</title>
    <link href="https://blog.twinklelabs.kr/design-system/same-size-comment-binds-nothing.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/design-system/same-size-comment-binds-nothing.html</id>
    <published>2026-08-23T12:50:00.000Z</published>
    <updated>2026-08-23T12:50:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Design System"/>
    <category term="design-system"/>
    <category term="compose"/>
    <category term="네이밍"/>
    <category term="토큰"/>
    <summary type="html">두 배지가 각자 24.dp 리터럴을 들고 '저쪽과 같은 크기'라는 주석으로만 묶여 있었어요. 같은 꼴을 찾다가, 주석이 같다고 말하는 채로 값이 이미 갈라진 것을 봤습니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 색 표가 여섯 벌이 되어 그중 하나가 몰래 달라진 이야기를 했어요.
&lt;code&gt;light-dark()&lt;/code&gt; 한 줄로 표를 한 벌로 만든 것이 결론이었죠.&lt;/p&gt;
&lt;p&gt;이번 글은 같은 병을 치수에서 본 이야기입니다.
그런데 이번엔 값을 묶어주던 게 CSS 블록도 아니었어요. 그냥 주석 한 줄이었습니다.&lt;/p&gt;
&lt;p&gt;결과 카드의 &lt;code&gt;IconBadge&lt;/code&gt;와 이력의 &lt;code&gt;KindBadge(iconOnly)&lt;/code&gt;가 같은 크기여야 했어요.
코드는 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// ResultCard.kt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;Box&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(modifier &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;size&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;24&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.dp))   &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// KindBadge 와 같은 크기&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// HistoryRow.kt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;Box&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(modifier &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;size&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;24&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.dp))   &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// IconBadge 와 같은 크기&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;값은 맞았어요. 그런데 묶여 있지는 않았습니다.&lt;/p&gt;
&lt;h1 id=&quot;주석은-읽는-사람에게만-말합니다&quot;&gt;주석은 읽는 사람에게만 말합니다&lt;/h1&gt;
&lt;p&gt;한쪽을 26으로 고치면 다른 쪽은 24로 남아요. 컴파일러는 아무 말도 안 합니다.
그리고 한쪽을 고치는 사람은 대개 다른 쪽을 읽지 않아요.&lt;/p&gt;
&lt;p&gt;주석의 문제는 틀릴 수 있다는 게 아니에요. 주석은 강제하는 힘이 없다는 겁니다.
“같아야 한다”는 사실이 사람의 기억에만 얹혀 있는 거죠.&lt;/p&gt;
&lt;p&gt;이름은 다릅니다. 컴파일러가 따라가요. 한 번 고치면 둘이 같이 움직입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// Badges.kt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;internal&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; KindBadgeIconOnlySize &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 24&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.dp&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 두 자리가 이 이름을 가리킵니다.&lt;/p&gt;
&lt;h1 id=&quot;같은-꼴을-찾다가-이미-어긋난-것을-봤어요&quot;&gt;같은 꼴을 찾다가 이미 어긋난 것을 봤어요&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;// 저쪽과 같은&lt;/code&gt;이라는 주석을 전부 찾아봤습니다. 그러다 이걸 봤어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// StockManageScreen.kt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;IconButton&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(modifier &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Modifier.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;size&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;32&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;.dp))   &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// IconBadgeSize 와 맞춤&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;IconBadgeSize&lt;/code&gt;는 28입니다.&lt;/p&gt;
&lt;p&gt;위쪽의 주석 둘은 “같은 크기(28dp)“라고 말하고 있었어요.
주석이 “같다”고 말하는 채로 값은 이미 갈라져 있었습니다.&lt;/p&gt;
&lt;p&gt;언제 갈라졌는지는 모릅니다. 아무도 몰라요. 그게 요점이에요.&lt;/p&gt;
&lt;h1 id=&quot;어긋난-것은-따로-갑니다&quot;&gt;어긋난 것은 따로 갑니다&lt;/h1&gt;
&lt;p&gt;여기서 한 가지를 참아야 했어요.&lt;/p&gt;
&lt;p&gt;값이 같은 동안에는 이름 하나로 묶어도 화면이 안 바뀝니다. 안전한 리팩토링이에요.&lt;/p&gt;
&lt;p&gt;그런데 값이 이미 다르면 이름을 가리키게 하는 순간 화면이 바뀝니다.
32였던 버튼이 28이 되거나, 28이었던 배지가 32가 돼요.&lt;/p&gt;
&lt;p&gt;그러니 이건 리팩토링이 아니라 디자인 변경입니다.
띄워보고 어느 쪽이 맞는지 정한 뒤에 가야 해요. 커밋도 따로 갑니다.&lt;/p&gt;
&lt;h1 id=&quot;그런데-사다리-토큰을-가리키지는-않았어요&quot;&gt;그런데 사다리 토큰을 가리키지는 않았어요&lt;/h1&gt;
&lt;p&gt;여기서 유혹이 하나 더 있었습니다. “토큰이 있는데 왜 새 상수를 만들지?”&lt;/p&gt;
&lt;p&gt;우리 아이콘 사다리는 이래요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;12 · 16 · 20 · 26&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;24가 없습니다. 그리고 간격 사다리에는 &lt;code&gt;Spacing.s3&lt;/code&gt;가 24로 있어요.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;Spacing.s3&lt;/code&gt;를 가리키고 싶어집니다. 값이 맞으니까요.&lt;/p&gt;
&lt;p&gt;안 했어요. 배지 한 변이 간격 한 칸과 같은 숫자인 것은 우연이거든요.&lt;/p&gt;
&lt;p&gt;간격이 바뀐다고 배지가 따라 바뀔 이유가 없습니다.
우연을 묶으면 나중에 간격 사다리를 손볼 때 배지가 같이 움직여요.
그리고 그때 왜 움직였는지 아무도 모릅니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;컴포넌트의 광학 치수는 그 파일의 이름 붙은 상수로 둡니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;토큰은 “같은 이유로 같아야 하는 것”을 묶는 도구예요.
“지금 숫자가 같은 것”을 묶는 도구가 아닙니다.&lt;/p&gt;
&lt;h1 id=&quot;이름은-읽히는-대로-짓습니다&quot;&gt;이름은 읽히는 대로 짓습니다&lt;/h1&gt;
&lt;p&gt;처음에 &lt;code&gt;KindBadgeIconSize&lt;/code&gt;라고 지었다가 바꿨어요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;KindBadgeIconSize&lt;/code&gt; → “배지 안 아이콘의 크기”로 읽힙니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;KindBadgeIconOnlySize&lt;/code&gt; → “아이콘만 남긴 모드의 상자 한 변”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;우리가 묶으려던 건 후자였어요.
앞의 이름을 그대로 뒀으면 다음 사람이 아이콘 크기를 고치려다 상자를 고쳤을 겁니다.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;// 저쪽과 같은&lt;/code&gt;이라는 주석을 보면 이름 하나로 바꿉니다.&lt;/li&gt;
&lt;li&gt;값이 같은 동안은 안전한 리팩토링이고, 이미 어긋났으면 디자인 변경이에요. 따로 갑니다.&lt;/li&gt;
&lt;li&gt;숫자가 같다고 묶지 않습니다. 같은 이유로 같아야 하는 것만 묶어요.&lt;/li&gt;
&lt;li&gt;이름은 읽히는 대로 짓습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같아야 하는 것은 주석에 적을 게 아니라 한 곳에 두고 가리킬 것이었어요.
지난 글과 정확히 같은 법입니다. 거기서는 색이었고 여기서는 치수일 뿐이에요.&lt;/p&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;p&gt;두 편에 걸쳐 한 벌로 두는 법을 이야기했어요. 색으로 한 번, 치수로 한 번요.&lt;/p&gt;
&lt;p&gt;마지막 편에서는 그렇게 자란 시스템 전체를 소개할게요.
이름이 어디서 왔는지, 사다리가 몇 칸인지, 그리고 되돌린 결정 셋에 대한 이야기입니다.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/design-system/aurora-ledger.html&quot;&gt;3 — Aurora Ledger - CSS 주석 한 줄에서 태어난 디자인 시스템&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 4 — 검증은 한 번, 증명은 타입이 들고 다닙니다</title>
    <link href="https://blog.twinklelabs.kr/android/validate-once-type-carries-proof.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/validate-once-type-carries-proof.html</id>
    <published>2026-08-23T12:20:00.000Z</published>
    <updated>2026-08-23T12:20:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="kotlin"/>
    <category term="구조"/>
    <category term="타입"/>
    <summary type="html">같은 날 버그 둘이 나왔어요. 하나는 같은 규칙이 두 곳에 다르게 적혀 있었고, 하나는 아무도 검증하지 않는 칸이 있었습니다. 값을 맞추는 데서 멈추면 셋째 버그가 옵니다.</summary>
    <content type="html">&lt;p&gt;지난 세 편은 화면과 문장이 조용히 틀어진 이야기였어요.
슬롯을 비웠더니 남의 글꼴이 들어왔고, 그늘로는 삼각형을 못 세웠고,
두 줄로 적은 문자열이 한 줄로 접혔죠.&lt;/p&gt;
&lt;p&gt;이번 글은 조금 안쪽입니다. 같은 날 버그가 둘 나왔는데,
따로 보면 사소한 실수였는데 나란히 놓으니 같은 병이더라고요.
그리고 그 병은 값을 고쳐서는 안 낫는 종류였습니다.&lt;/p&gt;
&lt;p&gt;먼저 버그 둘을 보여드릴게요.&lt;/p&gt;
&lt;p&gt;하나. 단가에 &lt;code&gt;0&lt;/code&gt;을 넣으면 오류도 결과도 안 나왔습니다. 화면이 아무 말도 안 했어요.&lt;/p&gt;
&lt;p&gt;둘. 목표 평단가 칸은 아무도 검증하지 않았습니다. &lt;code&gt;0&lt;/code&gt;을 친 사람에게 엉뚱한 이유를 댔어요.&lt;/p&gt;
&lt;p&gt;각각 고치는 건 어렵지 않았어요. 문제는 그다음이었습니다.&lt;/p&gt;
&lt;h1 id=&quot;하나--같은-규칙이-두-곳에-다르게-적혀-있었어요&quot;&gt;하나 — 같은 규칙이 두 곳에 다르게 적혀 있었어요&lt;/h1&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 검증하는 쪽&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; validatePrice&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(price: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Double&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;?): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Error&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;? &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#FF9E64&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) Error.Negative &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#FF9E64&quot;&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 계산하는 쪽&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; isValid &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#FF9E64&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;#x3C; 0&lt;/code&gt;과 &lt;code&gt;&gt; 0&lt;/code&gt;. &lt;code&gt;0&lt;/code&gt;이 두 판정 사이의 틈으로 빠졌습니다.&lt;/p&gt;
&lt;p&gt;검증하는 쪽은 “0은 음수가 아니니 통과”라고 했고,
계산하는 쪽은 “0은 양수가 아니니 계산 안 함”이라고 했어요.
그래서 오류도 없고 결과도 없었습니다.&lt;/p&gt;
&lt;h1 id=&quot;둘--검사-함수의-네-번째-자리&quot;&gt;둘 — 검사 함수의 네 번째 자리&lt;/h1&gt;
&lt;p&gt;입력 칸이 넷이었고 검사 함수가 넷을 받았어요.
그런데 마지막 자리의 이름이 &lt;code&gt;BUY_QUANTITY&lt;/code&gt;였습니다.&lt;/p&gt;
&lt;p&gt;목표 평단가 모드에는 “살 수량” 칸이 없어요.
그래서 그 모드는 네 번째 자리에 &lt;code&gt;null&lt;/code&gt;을 넘겼고,
그 자리에 실제로 서 있던 목표 평단가 칸이 통째로 검증을 빠져나갔습니다.&lt;/p&gt;
&lt;p&gt;이름이 착각을 만든 거예요.
&lt;code&gt;BUY_QUANTITY&lt;/code&gt;라는 자리 이름이 “목표 모드에는 검사할 칸이 없다”는 생각의 뿌리였습니다.&lt;/p&gt;
&lt;p&gt;자리는 자리 이름(&lt;code&gt;FOURTH&lt;/code&gt;)으로, 뜻은 따로(&lt;code&gt;InputKind&lt;/code&gt;)로 뒀어야 했어요.&lt;/p&gt;
&lt;h1 id=&quot;고친-뒤에도-구조는-그대로였습니다&quot;&gt;고친 뒤에도 구조는 그대로였습니다&lt;/h1&gt;
&lt;p&gt;값을 맞추고 &lt;code&gt;null&lt;/code&gt;을 없앴어요. 두 버그는 사라졌습니다.
그런데 구조가 안 바뀌었어요.&lt;/p&gt;
&lt;p&gt;계산 함수는 여전히 이렇게 생겼거든요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; calculate&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(price: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Double&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;?, quantity: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Double&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;?, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Result&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;? {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; isValid &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#FF9E64&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; /* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;   // ← 또 셉니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;isValid) &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-dark:#FF9E64&quot;&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Double?&lt;/code&gt;을 받는 함수는 누가 부르든 제 몸을 지키려고 다시 셉니다.
그게 두 번째 사본이에요. 사본이 있는 한 셋째 버그는 시간 문제였습니다.&lt;/p&gt;
&lt;h1 id=&quot;왜-두-번째-사본이-생길까요&quot;&gt;왜 두 번째 사본이 생길까요&lt;/h1&gt;
&lt;p&gt;“한 규칙이 두 곳에 적히면 갈린다”는 건 다들 알아요.
알면서도 두 곳에 적게 되는 이유가 있습니다.&lt;/p&gt;
&lt;p&gt;호출하는 쪽이 검증됐는지를 알 길이 없어서예요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;calculate(price: Double?)&lt;/code&gt;라는 서명은 “검증된 값을 주세요”라고 말할 방법이 없습니다.
&lt;code&gt;Double?&lt;/code&gt;은 그냥 숫자거나 없음이니까요.
그러니 받는 쪽은 방어하고, 그 방어가 사본이 됩니다.&lt;/p&gt;
&lt;h1 id=&quot;타입이-이미-검증됐다를-들고-오게-했어요&quot;&gt;타입이 “이미 검증됐다”를 들고 오게 했어요&lt;/h1&gt;
&lt;p&gt;그래서 걷었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;sealed&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; interface&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Validation&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Valid&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; inputs: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;ValidInputs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Validation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Incomplete&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Validation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    data&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; Invalid&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; kind: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;InputKind&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; reason: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Reason&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Validation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; validateInputs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(raw: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;RawInputs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Validation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 계산 함수는 ValidInputs 만 받습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; calculate&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(inputs: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;ValidInputs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Result&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;바뀐 게 세 가지예요.&lt;/p&gt;
&lt;p&gt;판정이 한 곳에서만 납니다. &lt;code&gt;validateInputs&lt;/code&gt; 하나가 셋 중 하나를 냅니다.
결과를 만드는 쪽과 오류 문구를 만드는 쪽이 같은 판정에서 갈라져요.
전에는 각자 세다가 서로 다른 답을 냈거든요.&lt;/p&gt;
&lt;p&gt;계산 함수가 다시 셀 이유가 없어졌습니다.
&lt;code&gt;ValidInputs&lt;/code&gt;를 들고 있다는 건 이미 통과했다는 증명이니까요.
방어할 것이 없으니 사본도 없습니다.&lt;/p&gt;
&lt;p&gt;검증 안 된 값은 컴파일이 안 됩니다. 이게 제일 커요.
“검증을 깜빡한 경로”가 생길 수 없습니다. 그런 코드는 빌드가 안 되니까요.
둘째 버그(아무도 검증하지 않는 칸)는 이 구조에서 적히는 것 자체가 불가능해졌어요.&lt;/p&gt;
&lt;h1 id=&quot;덤으로-null이-하나의-뜻만-갖게-됐어요&quot;&gt;덤으로 &lt;code&gt;null&lt;/code&gt;이 하나의 뜻만 갖게 됐어요&lt;/h1&gt;
&lt;p&gt;전에는 &lt;code&gt;null&lt;/code&gt;이 두 가지를 뜻했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;틀린 입력 — 사용자가 잘못 쳤다&lt;/li&gt;
&lt;li&gt;닿을 수 없다 — 아직 다 안 쳤다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;받는 쪽은 둘을 구분할 수 없으니 추측했어요.
그리고 그 추측이 화면의 문장이 됐습니다.
&lt;code&gt;0&lt;/code&gt;을 친 사람에게 엉뚱한 이유를 댄 게 바로 이거였어요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Incomplete&lt;/code&gt;와 &lt;code&gt;Invalid&lt;/code&gt;가 갈리자 추측할 일이 없어졌습니다.&lt;/p&gt;
&lt;h1 id=&quot;예외-하나--검증-앞에서-불리는-힌트&quot;&gt;예외 하나 — 검증 앞에서 불리는 힌트&lt;/h1&gt;
&lt;p&gt;치는 중에 ”≈ n주”라는 힌트를 보여줍니다. 이건 검증 전에 불려요.
&lt;code&gt;0&lt;/code&gt;을 받아도 무언가 보여줘야 하니까 &lt;code&gt;ValidInputs&lt;/code&gt;를 요구할 수 없습니다.&lt;/p&gt;
&lt;p&gt;그 자리의 검사는 남기고, KDoc에 사정을 적었어요.
예외를 두는 건 괜찮은데 왜 예외인지 적히지 않은 예외가 다음 사본의 씨앗이 되거든요.&lt;/p&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;규칙이 두 곳에 적힌 것을 보면 값을 맞추는 데서 멈추지 않습니다.
왜 두 번째 사본이 필요했는가를 물어요.
대개 답은 “호출하는 쪽이 모르기 때문”이고, 그 답의 답은 검증된 값의 타입입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;null&lt;/code&gt;이나 &lt;code&gt;Optional&lt;/code&gt;에 뜻을 둘 담지 않습니다.
둘이면 받는 쪽이 추측하고, 추측은 화면에서 말이 돼요.&lt;/li&gt;
&lt;li&gt;자리 이름과 뜻을 섞지 않습니다.
&lt;code&gt;BUY_QUANTITY&lt;/code&gt;가 네 번째 자리의 이름이 되는 순간, 그 자리에 다른 것이 설 때 아무도 못 알아봅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;p&gt;이번 글이 “값이 아니라 구조를 고친” 첫 이야기였어요.
그리고 이날 이후로 하루를 통째로 구조에 쓰게 됩니다.&lt;/p&gt;
&lt;p&gt;마지막 편은 그 하루 이야기예요.
손으로 만든 DI를 Hilt로, 손으로 만든 화면 전환을 Navigation으로 옮긴 날입니다.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/hilt-navigation-cleanup.html&quot;&gt;5 — 하루에 구조를 세 번 바꿨어요 - Hilt · Navigation · 걷어낸 것들&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 3 — 해시를 파일에 적는 순간 그 파일은 이력의 일부가 됩니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/hash-in-a-file-is-history.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/hash-in-a-file-is-history.html</id>
    <published>2026-08-23T11:40:00.000Z</published>
    <updated>2026-08-23T11:40:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="git"/>
    <category term="자동화"/>
    <category term="기록"/>
    <summary type="html">커밋 154개의 작성자를 고치려고 이력을 다시 썼어요. 그 뒤 검사 하나가 조용히 bad object로 떨어져 있었습니다. 깨질 거라고 미리 적어두었는데도 깨졌어요.</summary>
    <content type="html">&lt;p&gt;지난 글의 결론은 “확인하려면 해시를 보라”였어요.
스토어에 올라간 이미지의 sha1과 내 파일의 해시를 맞춰봐야 확인이라는 이야기였죠.&lt;/p&gt;
&lt;p&gt;이번 글은 그 해시를 파일에 적어두면 어떻게 되는지에 대한 이야기입니다.
그리고 이 시리즈에서 제일 뼈아픈 편이기도 해요.
깨질 걸 미리 알고 적어뒀는데도 깨졌거든요.&lt;/p&gt;
&lt;p&gt;일이 이렇게 시작됐습니다.
커밋 작성자가 옛 메일로 남아 있었어요. 물타기 계산기 122개, 루트 32개, 합쳐서 154개요.
&lt;code&gt;filter-branch&lt;/code&gt;로 이력을 다시 썼습니다.&lt;/p&gt;
&lt;p&gt;며칠 뒤에 보니 스토어 사진 검사(&lt;code&gt;fresh.sh&lt;/code&gt;)가 떨어져 있었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;fatal: bad object 8c7235f…&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;무엇이-깨졌나&quot;&gt;무엇이 깨졌나&lt;/h1&gt;
&lt;p&gt;이 검사는 “스토어 스크린샷이 최신 화면과 같은가”를 봅니다. 방법은 이래요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;reviewed-at&lt;/code&gt;이라는 파일에 사람이 눈으로 봐둔 시점의 커밋 해시를 적어둡니다.
검사는 그 해시를 읽어서 &lt;code&gt;git log -1 &amp;#x3C;해시&gt;&lt;/code&gt;로 그때 이후에 화면 코드가 바뀌었는지 봐요.&lt;/p&gt;
&lt;p&gt;그런데 이력을 다시 쓰면서 그 해시가 새 이력에 없는 이름이 됐습니다.&lt;/p&gt;
&lt;h1 id=&quot;알고-있었는데도-깨졌어요&quot;&gt;알고 있었는데도 깨졌어요&lt;/h1&gt;
&lt;p&gt;이 부분이 제일 뼈아픕니다.&lt;/p&gt;
&lt;p&gt;백로그에 이렇게 적혀 있었거든요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;이력을 다시 쓰면 &lt;code&gt;reviewed-at&lt;/code&gt;도 어긋난다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;미리 적어뒀어요.
그날 새벽에 잘못 섞인 커밋을 되감지 않은 이유이기도 했습니다.
“되감으면 저게 깨진다”를 알고 있었으니까요.&lt;/p&gt;
&lt;p&gt;그런데 정작 계획된 재작성에서는 그 자리를 잊었습니다.
알고 있는 것과 그 순간 손이 그 자리로 가는 것은 다르더라고요.
그리고 도구는 그 자리를 몰랐고요.&lt;/p&gt;
&lt;h1 id=&quot;왜-조용했을까요&quot;&gt;왜 조용했을까요&lt;/h1&gt;
&lt;p&gt;해시는 이력 안에서만 뜻이 있어요.
이력이 다시 쓰이면 옛 해시는 어느 저장소에도 없는 이름이 됩니다.&lt;/p&gt;
&lt;p&gt;그때 그것을 읽는 검사는 “틀렸다”로 떨어지지 않아요. “없다”로 떨어집니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;fatal: bad object …&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bad object&lt;/code&gt;는 “비교해보니 다르다”가 아니라 “비교할 대상이 없다”예요.
그리고 없는 것은 소리를 내지 않습니다.&lt;/p&gt;
&lt;p&gt;검사는 실패했는데, 그 실패가 “스크린샷이 낡았다”는 신호로 읽히지 않았어요.&lt;/p&gt;
&lt;h1 id=&quot;두-번째-재작성에서-같이-옮겼습니다&quot;&gt;두 번째 재작성에서 같이 옮겼습니다&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;filter-branch&lt;/code&gt;의 &lt;code&gt;map&lt;/code&gt;을 써서 두 가지를 함께 새 해시로 옮겼어요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;reviewed-at&lt;/code&gt; 파일 안의 해시&lt;/li&gt;
&lt;li&gt;그 해시를 본문에 적은 커밋 메시지 둘&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;두 번째를 빠뜨릴 뻔했습니다. 커밋 메시지 안에도 해시가 있었거든요.&lt;/p&gt;
&lt;h1 id=&quot;검사가-읽는-해시와-사람이-읽는-해시를-가릅니다&quot;&gt;검사가 읽는 해시와 사람이 읽는 해시를 가릅니다&lt;/h1&gt;
&lt;p&gt;기록 금고의 일지에도 옛 해시가 잔뜩 적혀 있어요. 그건 안 고쳤습니다.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;검사가 읽는 해시&lt;/th&gt;&lt;th&gt;사람이 읽는 해시&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;어디&lt;/td&gt;&lt;td&gt;&lt;code&gt;reviewed-at&lt;/code&gt; 같은 파일&lt;/td&gt;&lt;td&gt;일지·기록&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;이력을 고치면&lt;/td&gt;&lt;td&gt;같이 고칩니다&lt;/td&gt;&lt;td&gt;그대로 둡니다&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;왜&lt;/td&gt;&lt;td&gt;읽는 도구가 있고, 없으면 깨지니까&lt;/td&gt;&lt;td&gt;“그때 그렇게 적혔다”는 사실이니까&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;일지의 옛 해시를 고치면 오히려 거짓이 돼요.
되돌린 결정을 지우지 않는 것과 같은 이유입니다.
기록은 그때의 사실이지 현재 상태가 아니니까요.&lt;/p&gt;
&lt;h1 id=&quot;다음엔-이렇게-하려고요&quot;&gt;다음엔 이렇게 하려고요&lt;/h1&gt;
&lt;h2 id=&quot;이력을-다시-쓰기-전에-해시를-담은-파일을-먼저-찾습니다&quot;&gt;이력을 다시 쓰기 전에 해시를 담은 파일을 먼저 찾습니다&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#E0AF68&quot;&gt; -E&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; '&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;[0-9a-f]{7,40}&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;찾은 자리를 &lt;code&gt;filter-branch&lt;/code&gt;의 &lt;code&gt;map&lt;/code&gt;이나 &lt;code&gt;git replace&lt;/code&gt;로 같이 옮겨요.&lt;/p&gt;
&lt;h2 id=&quot;검사가-해시를-읽으면-없는-해시를-오류로-냅니다&quot;&gt;검사가 해시를 읽으면 “없는 해시”를 오류로 냅니다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bad object&lt;/code&gt;가 통과나 조용한 실패가 되지 않게 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; cat-file&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#E0AF68&quot;&gt; -e&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#C0CAF5&quot;&gt;$REVIEWED_AT&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;^{commit}&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; 2&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-light-font-style:italic;--shiki-dark:#0DB9D7;--shiki-dark-font-style:inherit&quot;&gt;  echo&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;reviewed-at 의 해시가 이 이력에 없다 — 이력이 다시 쓰였나?&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt; &gt;&amp;#x26;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D20F39;--shiki-light-font-style:italic;--shiki-dark:#0DB9D7;--shiki-dark-font-style:inherit&quot;&gt;  exit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;“없다”와 “낡았다”는 사람에게 다른 말이어야 하니까요.&lt;/p&gt;
&lt;h2 id=&quot;어디-있는지를-백로그가-아니라-순서에-적습니다&quot;&gt;어디 있는지를 백로그가 아니라 순서에 적습니다&lt;/h2&gt;
&lt;p&gt;백로그에 적어둔 것은 읽을 이유가 있을 때만 읽힙니다.
그런데 이력을 고치는 사람은 그날 백로그를 안 봐요.&lt;/p&gt;
&lt;p&gt;그러니 “이력 재작성 절차”라는 문서에 적습니다.
그 문서는 그 일을 하는 사람이 반드시 여는 문서니까요.&lt;/p&gt;
&lt;h2 id=&quot;이번에-확인한-것&quot;&gt;이번에 확인한 것&lt;/h2&gt;
&lt;p&gt;세 편의 이야기가 조금씩 달랐어요.&lt;/p&gt;
&lt;p&gt;1 도구가 잘못 판정했고, 2 도구가 통째로 실패했는데 말하지 않았고,
3 검사가 비교할 대상을 잃고도 그냥 떨어졌습니다.&lt;/p&gt;
&lt;p&gt;그런데 공통점이 하나 있어요.
셋 다 “돌렸다”까지는 성공했다는 겁니다.&lt;/p&gt;
&lt;p&gt;그래서 결론도 하나예요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;자동화의 끝은 “돌렸다”가 아니라 “확인했다”입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;그리고 확인의 방법은 매번 달랐어요.
겹침 판정은 사람이 같은 일로 알아보는 열쇠로, 업로드는 해시 대조로,
검사는 “없는 것”을 오류로 만드는 것으로요.&lt;/p&gt;
&lt;p&gt;마지막으로 하나만 덧붙일게요.
해시를 파일에 적는 건 아주 흔한 일이에요. 락 파일, 캐시 키, “여기까지 확인함” 표시.
전부 편하고, 전부 같은 성질을 갖습니다.&lt;/p&gt;
&lt;p&gt;그 파일은 코드가 아니라 이력의 일부예요.
그러니 이력을 고치는 손이 그 파일도 같이 고쳐야 합니다.&lt;/p&gt;
&lt;p&gt;세 편을 마치고 하루 뒤, 같은 병을 &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt; 안에서 또 만났어요.
이번엔 결과물이 0개인데 빌드가 성공했습니다.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/silent-failures/seo-head-lies.html&quot;&gt;4 — 정본 주소가 404를 가리켜도 화면은 멀쩡히 뜹니다&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 2 — 원자적인 편집은 통째로 실패하고, 무엇이 빠졌는지 말하지 않아요</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/atomic-edit-says-nothing.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/atomic-edit-says-nothing.html</id>
    <published>2026-08-23T11:10:00.000Z</published>
    <updated>2026-08-23T11:10:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="play-store"/>
    <category term="출시"/>
    <category term="gradle"/>
    <category term="자동화"/>
    <summary type="html">스토어 이미지가 며칠째 안 바뀌어 있었어요. 없는 로케일 하나 때문에 이미 있던 로케일의 이미지까지 통째로 버려지고 있었고, 로그 끝에는 FAILED 한 줄뿐이었습니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 도구가 겹침을 잘못 판정한 이야기를 했어요.
해시로는 저장소를 넘나드는 같은 일을 알아볼 수 없다는 이야기였죠.
그래도 그건 결과물이 남긴 남았습니다.&lt;/p&gt;
&lt;p&gt;이번 건 더 고약해요. 아무것도 안 올라갔는데 그 사실을 아무도 말해주지 않았습니다.&lt;/p&gt;
&lt;p&gt;구글 플레이 등록정보를 Gradle Play Publisher(GPP)로 올리고 있어요.
명령 한 줄이면 텍스트도 이미지도 다 올라갑니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;./gradlew&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; publishReleaseListing&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;돌렸어요. 그런데 며칠이 지났는데 스토어 이미지가 안 바뀌어 있었습니다.&lt;/p&gt;
&lt;h1 id=&quot;텍스트와-이미지가-한-편집edit에-들어갑니다&quot;&gt;텍스트와 이미지가 한 편집(edit)에 들어갑니다&lt;/h1&gt;
&lt;p&gt;플레이 API는 “편집(edit)“이라는 단위로 움직여요.
편집을 열고, 그 안에서 이것저것 바꾸고, 마지막에 커밋합니다.
커밋 전까지는 아무것도 반영되지 않고, 커밋하면 전부 반영돼요.&lt;/p&gt;
&lt;p&gt;원자적입니다. 반쯤 올라간 스토어가 생기지 않아요.&lt;/p&gt;
&lt;p&gt;그리고 GPP 4.1.1은 등록정보 텍스트와 이미지를 같은 편집에서 올립니다.
그러니 이 둘은 함께 성공하거나 함께 실패해요.&lt;/p&gt;
&lt;h1 id=&quot;없는-로케일-하나가-전부를-죽였어요&quot;&gt;없는 로케일 하나가 전부를 죽였어요&lt;/h1&gt;
&lt;p&gt;새 언어를 추가하면서 &lt;code&gt;graphics/&lt;/code&gt; 폴더를 여러 로케일에 만들어뒀습니다.
그중 일부는 스토어에 아직 존재하지 않는 로케일이었어요.&lt;/p&gt;
&lt;p&gt;GPP는 이미지를 올리기 전에 현재 이미지 목록을 GET으로 가져옵니다.
그런데 스토어에 없는 로케일에서는 이게 404로 죽어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Listing for language 'fr-FR' not found&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 그 편집은 원자적이므로,
이미 잘 있던 ko-KR의 이미지까지 통째로 버려졌습니다.&lt;/p&gt;
&lt;p&gt;빌드 로그 끝은 이랬어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;FAILED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;원자성의-뒷면&quot;&gt;원자성의 뒷면&lt;/h1&gt;
&lt;p&gt;원자성은 좋은 성질이에요. 반쯤 올라간 스토어를 막아주니까요.&lt;/p&gt;
&lt;p&gt;대신 실패의 원인이 전체의 아무 데나 있을 수 있게 합니다.
그리고 로그는 마지막 예외만 보여줘요.
“무엇이 안 올라갔는가”는 로그에 없습니다. 스토어 쪽을 열어봐야 알아요.&lt;/p&gt;
&lt;p&gt;여기서 두 번째 함정이 나옵니다.
콘솔은 “검토 중인 것”과 “올라간 것”을 구분해주지 않아요.
눈으로 보면 바뀐 것 같아 보입니다.
그래서 콘솔이 아니라 API를 봐야 해요.&lt;/p&gt;
&lt;h1 id=&quot;확인하는-법--sha1을-맞춰봅니다&quot;&gt;확인하는 법 — sha1을 맞춰봅니다&lt;/h1&gt;
&lt;p&gt;편집을 하나 만들고, 이미지 목록을 읽고, 해시를 로컬 파일과 맞춰보고, 편집을 지웁니다.
읽기만 하니까 지워도 아무 일도 안 일어나요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 1. edit 을 만듭니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 2. GET listings/{lang}/phoneScreenshots → 각 이미지의 sha1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 3. 로컬 파일의 shasum 과 맞춥니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;shasum&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; fastlane/metadata/android/ko-KR/images/phoneScreenshots/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#F7768E&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;.png&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;# 4. edit 을 DELETE&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;해시가 맞으면 올라간 것이고, 다르면 안 올라간 거예요.
눈으로 본 콘솔은 증거가 아닙니다.&lt;/p&gt;
&lt;h1 id=&quot;우회로--두-번-돌립니다&quot;&gt;우회로 — 두 번 돌립니다&lt;/h1&gt;
&lt;p&gt;스토어에 없는 로케일이 있으면 이렇게 해요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;새 로케일의 &lt;code&gt;graphics/&lt;/code&gt;를 잠깐 치워두고 한 번 돌립니다 → 로케일이 생깁니다&lt;/li&gt;
&lt;li&gt;되돌리고 다시 돌립니다 → 이미지가 올라갑니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;저장소 트리는 결국 그대로예요. 명령을 두 번 돌릴 뿐입니다.&lt;/p&gt;
&lt;h2 id=&quot;막힌-길-하나&quot;&gt;막힌 길 하나&lt;/h2&gt;
&lt;p&gt;“REST로 직접 쳐서 로케일만 먼저 만들면 되지 않나?” 싶어서 해봤는데 안 됐어요.
&lt;code&gt;edits:validate&lt;/code&gt;는 200을 주는데 &lt;code&gt;edits:commit&lt;/code&gt;이 HTML 404를 줍니다.&lt;/p&gt;
&lt;p&gt;결국 커밋은 플러그인에게 맡기는 수밖에 없었어요.
되돌린 시도지만 적어둡니다. 안 적으면 다음에 또 같은 자리를 파거든요.&lt;/p&gt;
&lt;h1 id=&quot;같은-꼴을-전날에도-봤어요&quot;&gt;같은 꼴을 전날에도 봤어요&lt;/h1&gt;
&lt;p&gt;바로 전날 “소스 세트 자리” 함정에 걸렸었습니다.
파일을 엉뚱한 소스 세트에 두었는데 빌드가 아무 말도 안 하고 통과했어요.&lt;/p&gt;
&lt;p&gt;같은 병입니다. 도구가 조용히 실패하고, 돌린 쪽은 돌렸다고 믿는 것.&lt;/p&gt;
&lt;p&gt;그래서 규칙을 하나 세웠어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;배포는 “돌렸다”가 아니라 “확인했다”로 끝난다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;명령이 0을 반환한 것은 확인이 아니에요.
스토어에 있는 파일의 해시가 내 파일의 해시와 같은 것이 확인입니다.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;p&gt;이번 글의 결론은 “확인하려면 해시를 보라”였어요.
그런데 그 해시를 파일에 적어두면, 이번엔 그 파일이 새로운 함정이 됩니다.&lt;/p&gt;
&lt;p&gt;다음 글이 그 이야기예요. 그리고 깨질 걸 미리 알고 있었는데도 깨졌습니다.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/silent-failures/hash-in-a-file-is-history.html&quot;&gt;3 — 해시를 파일에 적는 순간 그 파일은 이력의 일부가 됩니다&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>디자인 시스템 세우기 1 — 라이트·다크를 네 벌 적으면 그중 하나가 몰래 달라집니다</title>
    <link href="https://blog.twinklelabs.kr/design-system/light-dark-one-copy.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/design-system/light-dark-one-copy.html</id>
    <published>2026-08-22T13:00:00.000Z</published>
    <updated>2026-08-22T13:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Design System"/>
    <category term="css"/>
    <category term="design-system"/>
    <category term="다크모드"/>
    <summary type="html">목업의 색 토큰 표가 여섯 벌 있었어요. 그 사이에서 이 문서 자신의 보라가, Aurora는 #7040D9라고 적어둔 주석 바로 아래에서 다른 보라로 그려지고 있었습니다.</summary>
    <content type="html">&lt;p&gt;디자인 시스템을 세우면서 배운 것들을 세 편으로 나눠 적어보려고 합니다.
셋 다 같은 질문에서 출발해요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;같아야 하는 값을 어떻게 진짜로 같게 유지하지?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;색 — 라이트·다크를 한 벌로 적는 법 — 지금 이 글&lt;/li&gt;
&lt;li&gt;치수 — “저쪽과 같은 크기”라는 주석은 아무것도 묶지 않는다&lt;/li&gt;
&lt;li&gt;그래서 만들어진 시스템 전체 — Aurora Ledger&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;첫 편은 색입니다. 이야기는 좀 부끄러운 장면에서 시작해요.&lt;/p&gt;
&lt;p&gt;디자인 시스템 목업을 열었다가 이상한 걸 봤거든요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* Aurora 는 #7040D9 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;--brand: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;#&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF5370&quot;&gt;6F3DE0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주석 바로 아래에서 다른 값이 그려지고 있었습니다.&lt;/p&gt;
&lt;p&gt;이 문서는 디자인 시스템을 설명하는 문서예요.
그 문서 자신의 브랜드색이 자기가 설명하는 값과 달랐던 겁니다.&lt;/p&gt;
&lt;h1 id=&quot;색-표가-여섯-벌-있었어요&quot;&gt;색 표가 여섯 벌 있었어요&lt;/h1&gt;
&lt;p&gt;세어보니 이랬습니다.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;




























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;벌&lt;/th&gt;&lt;th&gt;언제 이기나&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;:root&lt;/code&gt; (라이트)&lt;/td&gt;&lt;td&gt;기본&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;@media (prefers-color-scheme: dark)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;시스템이 다크일 때&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;[data-theme=&quot;dark&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;사용자가 다크를 고를 때&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;[data-theme=&quot;light&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;사용자가 라이트를 고를 때&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;.phone&lt;/code&gt; 스킨 × 3&lt;/td&gt;&lt;td&gt;목업 안의 폰 목업&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;같은 값의 표가 여섯 벌이었어요.&lt;/p&gt;
&lt;p&gt;그중 뒤에 온 블록 하나가 옛 보라(&lt;code&gt;#6F3DE0&lt;/code&gt;)를 들고 있었고,
CSS는 뒤에 온 것이 이기니까 그게 화면에 나왔습니다.&lt;/p&gt;
&lt;h1 id=&quot;진짜-문제는-고칠-자리가-몇-개인지-모르게-되는-것이에요&quot;&gt;진짜 문제는 “고칠 자리가 몇 개인지 모르게 되는 것”이에요&lt;/h1&gt;
&lt;p&gt;같은 값을 여러 자리에 적으면 흔히 “그중 하나를 고치는 걸 잊는다”고 말하죠.
그런데 실제로 일어나는 일은 좀 다릅니다.&lt;/p&gt;
&lt;p&gt;고칠 자리가 몇 개인지를 모르게 돼요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;#6F3DE0&lt;/code&gt;을 지우려고 검색하면 몇 개가 나올지 모릅니다.
전부 고쳤는지 확인할 방법도 없어요.
화면을 봐도 지금 보고 있는 테마만 보이고요.&lt;/p&gt;
&lt;p&gt;그리고 이게 테마 블록의 고약한 점입니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;라이트로 직접 골랐을 때만 틀린 값은,
시스템 다크로 개발하는 동안 영영 안 보입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;[data-theme=&quot;light&quot;]&lt;/code&gt; 블록은 사용자가 토글을 눌러 라이트를 고를 때만 적용돼요.
시스템이 라이트여도 &lt;code&gt;:root&lt;/code&gt; 기본값이 쓰이므로 이 블록은 안 탑니다.
개발자가 그 조합을 일부러 만들지 않으면 평생 안 보는 코드인 거죠.&lt;/p&gt;
&lt;h1 id=&quot;light-dark로-한-벌이-됐어요&quot;&gt;&lt;code&gt;light-dark()&lt;/code&gt;로 한 벌이 됐어요&lt;/h1&gt;
&lt;p&gt;CSS에 &lt;code&gt;light-dark()&lt;/code&gt;가 있습니다. 값 하나에 라이트·다크 두 값을 같이 적어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#E0AF68&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt;  color-scheme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF9E64&quot;&gt; light&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF9E64&quot;&gt; dark&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --token-bg&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt;      light-dark(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt;#&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;F8F6FC&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt; #&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;110D19&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --token-text&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt;    light-dark(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt;#&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;21182D&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt; #&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;FAF7FF&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --token-primary&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#BB9AF7&quot;&gt; light-dark(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt;#&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;7040D9&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9AA5CE&quot;&gt; #&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9AA5CE&quot;&gt;986CF4&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;어느 쪽을 쓸지는 그 요소의 &lt;code&gt;color-scheme&lt;/code&gt;이 정합니다.
그러니 테마를 직접 고르는 자리는 이 한 줄이면 끝이에요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#E0AF68&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;data-theme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;dark&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;  {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt; color-scheme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF9E64&quot;&gt; dark&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#E0AF68&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;data-theme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;light&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#7AA2F7&quot;&gt; color-scheme&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF9E64&quot;&gt; light&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;미디어 쿼리도 없고, 특정도 싸움도 없습니다. 표는 한 벌이에요.&lt;/p&gt;
&lt;h2 id=&quot;짧은-이름이-필요하면-가리키게-둡니다&quot;&gt;짧은 이름이 필요하면 가리키게 둡니다&lt;/h2&gt;
&lt;p&gt;화면 안에서 &lt;code&gt;--token-bg&lt;/code&gt;가 길면 짧은 이름을 두고 싶어져요.
그때 값을 옮겨 적으면 다시 두 벌이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* ✗ 두 벌이 됩니다 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;--bg: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;#&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#FF5370&quot;&gt;F8F6FC;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* ○ 가리킵니다 */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;--bg: var(--token-bg);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;color-scheme의-덤이-생각보다-커요&quot;&gt;&lt;code&gt;color-scheme&lt;/code&gt;의 덤이 생각보다 커요&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;color-scheme&lt;/code&gt;은 “어느 값을 고를까”만 정하는 게 아닙니다.
브라우저가 그리는 것들의 색이 따라와요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스크롤바&lt;/li&gt;
&lt;li&gt;폼 컨트롤 (&lt;code&gt;&amp;#x3C;input&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;select&gt;&lt;/code&gt;, 체크박스…)&lt;/li&gt;
&lt;li&gt;첫 캔버스(페이지 배경이 칠해지기 전의 바탕)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 선언을 빼면 브라우저는 페이지가 라이트라고 믿습니다.
그래서 공들여 만든 어두운 판 옆에 은색 스크롤바를 세워요.&lt;/p&gt;
&lt;p&gt;그건 “다크를 지원한다”가 아니라 “다크를 잊었다”로 읽힙니다.&lt;/p&gt;
&lt;p&gt;주의할 게 하나 있어요.
이 값은 테마를 stamp하는 루트 요소에 얹어야 합니다.
&lt;code&gt;body&lt;/code&gt;에만 걸면 뷰포트 스크롤바는 따라오지 않아요. 캔버스는 루트의 것을 보거든요.&lt;/p&gt;
&lt;h1 id=&quot;브라우저-지원은-이제-문제가-아니에요&quot;&gt;브라우저 지원은 이제 문제가 아니에요&lt;/h1&gt;
&lt;div class=&quot;table-wrap&quot;&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;지원 시작&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Chrome&lt;/td&gt;&lt;td&gt;123&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Safari&lt;/td&gt;&lt;td&gt;17.5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Firefox&lt;/td&gt;&lt;td&gt;120&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;색은 &lt;code&gt;light-dark()&lt;/code&gt; 한 줄로 적습니다.&lt;/li&gt;
&lt;li&gt;짧은 이름이 필요하면 가리키게 두지 옮겨 적지 않아요.&lt;/li&gt;
&lt;li&gt;테마를 직접 고르는 자리는 &lt;code&gt;color-scheme&lt;/code&gt;만 못 박습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;color-scheme&lt;/code&gt;을 루트에 선언해요. 스크롤바와 폼 컨트롤이 딸려 옵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;한 벌은 한 번만 적습니다.
두 벌이 되는 순간, 둘이 언제 달라졌는지 아무도 모르게 되니까요.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;p&gt;이번 글은 색이 여러 벌이 된 이야기였어요.
그런데 같은 병이 색에서만 생기는 건 아니더라고요.&lt;/p&gt;
&lt;p&gt;다음 글은 같은 병을 치수에서 본 이야기입니다.
그리고 이번엔 값을 묶어주던 게 CSS 블록도 아니고, 그냥 주석 한 줄이었어요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/design-system/same-size-comment-binds-nothing.html&quot;&gt;2 — “저쪽과 같은 크기”라는 주석은 아무것도 묶지 않습니다&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 3 — aapt는 줄바꿈을 공백 하나로 접습니다</title>
    <link href="https://blog.twinklelabs.kr/android/aapt-string-newline.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/aapt-string-newline.html</id>
    <published>2026-08-22T12:10:00.000Z</published>
    <updated>2026-08-22T12:10:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="i18n"/>
    <category term="strings"/>
    <summary type="html">strings.xml에 두 줄로 적은 문장이 화면에서는 띄어쓰기 하나로 이어 붙었어요. 네 문자열이 그랬고, 번역된 열한 언어가 똑같이 그랬습니다.</summary>
    <content type="html">&lt;p&gt;지난 두 편에서는 화면에서 조용히 틀어진 것들을 봤어요.
남의 글꼴이 들어온 슬롯, 그리고 그늘로는 세울 수 없던 삼각형이었죠.&lt;/p&gt;
&lt;p&gt;이번엔 문장입니다. 그리고 이건 한 언어에서 끝나지 않았어요.
원문이 앓으면 번역이 그 병을 그대로 물려받거든요.&lt;/p&gt;
&lt;p&gt;설정 화면에 이런 문장이 떠 있었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;선택 안 해도 계산 가능 나중에 이력에서 변경&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;한국어인데 뜻이 안 통하죠. “선택 안 해도 계산 가능”과 “나중에 이력에서 변경”,
두 문장이 하나로 붙어 있었어요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;strings.xml&lt;/code&gt;에는 이렇게 적혀 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;stock_optional_hint&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;선택 안 해도 계산 가능&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;나중에 이력에서 변경&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의도는 두 줄이었어요. 화면은 한 줄이었고요.&lt;/p&gt;
&lt;h1 id=&quot;잇단-공백을-하나로-접는데-줄바꿈도-공백입니다&quot;&gt;잇단 공백을 하나로 접는데, 줄바꿈도 공백입니다&lt;/h1&gt;
&lt;p&gt;안드로이드의 자원 컴파일러(aapt)는 문자열 자원 안의 잇단 공백을 공백 하나로 접습니다.
XML에서는 흔한 정리예요. 들여쓰기 때문에 생긴 공백이 화면에 나오면 곤란하니까요.&lt;/p&gt;
&lt;p&gt;문제는 줄바꿈도 공백이라는 것이었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;&amp;#x3C;!-- 적은 것 --&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;첫 줄&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;둘째 줄&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;&amp;#x3C;!-- 화면에 나오는 것 --&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;첫 줄 둘째 줄&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;줄을 바꾸고 싶으면 이렇게 적어야 해요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;첫 줄\n둘째 줄&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;왜-여태-안-보였을까요&quot;&gt;왜 여태 안 보였을까요&lt;/h1&gt;
&lt;p&gt;두 조각이 각각 마침표로 끝나면 티가 안 납니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“A입니다. B입니다.” → “A입니다. B입니다.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;붙어도 읽히죠. 그런데 우리 문장은 명사형 토막이었어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“선택 안 해도 계산 가능” + “나중에 이력에서 변경”
→ “선택 안 해도 계산 가능 나중에 이력에서 변경”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;마침표가 없으니 어디서 끊어야 할지 알 수가 없습니다.&lt;/p&gt;
&lt;p&gt;같은 병을 앓는 문자열이 넷이었고, 번역된 열한 언어가 전부 똑같이 앓았어요.&lt;/p&gt;
&lt;p&gt;여기서 일이 생각보다 커졌습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;원문이 두 줄이라, 번역도 두 줄로 옮겨졌습니다.&lt;/li&gt;
&lt;li&gt;접히는 것은 원문에서 일어나는 일이라 번역가가 알아챌 자리가 없어요.&lt;/li&gt;
&lt;li&gt;빌드도 린트도 아무 말이 없습니다.&lt;/li&gt;
&lt;li&gt;그 화면을 그 언어로 열어본 사람만 압니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;열한 언어를 전부 열어보는 사람은 없죠.
그래서 이건 “언젠가 누가 제보하면 아는” 종류의 버그였고,
제보가 안 오면 영영 그대로였을 거예요.&lt;/p&gt;
&lt;h1 id=&quot;검사를-세웠어요--그리고-일부러-틀린-줄을-먼저-넣어봤습니다&quot;&gt;검사를 세웠어요 — 그리고 일부러 틀린 줄을 먼저 넣어봤습니다&lt;/h1&gt;
&lt;p&gt;번역 완성도를 재는 검사(&lt;code&gt;TranslationCompletenessTest&lt;/code&gt;)에 규칙을 하나 더했어요.
날 줄바꿈이 든 문자열을 잡습니다.&lt;/p&gt;
&lt;p&gt;세울 때 한 가지를 반드시 했어요.
일부러 틀린 줄을 넣어보고, 검사가 잡는지 먼저 봤습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;1. strings.xml 에 두 줄짜리 문자열을 하나 심는다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2. 검사를 돌린다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;3. 빨간불이 뜨는 것을 확인한다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;4. 심은 것을 걷는다&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 절차를 건너뛰면 검사가 통과할 때 그게 “문제가 없다”인지
“검사가 아무것도 안 본다”인지 알 수 없어요.&lt;/p&gt;
&lt;p&gt;안 잡는 검사는 검사가 있다는 사실로 오히려 사람을 안심시킵니다.
그건 없는 것보다 나빠요.&lt;/p&gt;
&lt;h1 id=&quot;붙어도-뜻이-통하던-줄까지-같이-폈어요&quot;&gt;붙어도 뜻이 통하던 줄까지 같이 폈어요&lt;/h1&gt;
&lt;p&gt;넷 중 둘은 마침표로 끝나서 붙어도 읽혔습니다. 그래도 같이 폈어요.&lt;/p&gt;
&lt;p&gt;남겨두면 다음 사람이 그 파일을 열었을 때 “여기서는 줄이 나뉜다”고 믿게 되거든요.
그리고 그 믿음으로 새 문자열을 두 줄로 적습니다.&lt;/p&gt;
&lt;p&gt;틀린 예제 하나가 파일 안에 남아 있으면, 그건 예제가 아니라 규칙이 됩니다.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;문자열 자원 안에서는 줄을 바꾸지 않습니다. 줄이 필요하면 &lt;code&gt;\n&lt;/code&gt;으로 적어요.&lt;/li&gt;
&lt;li&gt;검사를 세울 때는 일부러 틀린 것을 넣어 잡히는지 먼저 봅니다.&lt;/li&gt;
&lt;li&gt;붙어도 뜻이 통하던 줄까지 같이 폅니다. 남은 하나가 다음 사람의 본보기가 되니까요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;번역은 원문의 병을 그대로 물려받습니다. 그러니 원문이 건강해야 해요.&lt;/p&gt;
&lt;h2 id=&quot;결국-바꾼-것&quot;&gt;결국 바꾼 것&lt;/h2&gt;
&lt;p&gt;여기까지 세 편은 전부 화면과 문장이 조용히 틀어진 이야기였어요.
전부 “그 자리를 직접 열어본 사람만 아는” 종류였고요.&lt;/p&gt;
&lt;p&gt;다음 글은 조금 안쪽으로 들어갑니다.
같은 날 버그 둘이 나왔는데, 나란히 놓고 보니 같은 병이었던 이야기예요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/validate-once-type-carries-proof.html&quot;&gt;4 — 검증은 한 번, 증명은 타입이 들고 다닙니다&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 2 — 위를 향한 꼭지는 그늘로 설 수 없어요</title>
    <link href="https://blog.twinklelabs.kr/android/compose-arrow-elevation.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/compose-arrow-elevation.html</id>
    <published>2026-08-22T11:40:00.000Z</published>
    <updated>2026-08-22T11:40:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="compose"/>
    <category term="design-system"/>
    <category term="elevation"/>
    <summary type="html">셀렉트 위에 붙인 삼각형 꼭지가 라이트 모드에서 사라졌어요. elevation을 아무리 올려도 살아나지 않았습니다. 값의 문제가 아니라 방향의 문제였어요.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 비워둔 타이포 슬롯이 Roboto로 채워지던 이야기를 했어요.
코드에서는 “없음”으로 보였는데 화면에서는 남의 글꼴이 서 있던 경우였죠.&lt;/p&gt;
&lt;p&gt;이번 글도 화면에서만 보이는 문제인데, 방향이 반대예요.
이번엔 분명히 그렸는데 화면에서 사라진 것입니다.&lt;/p&gt;
&lt;p&gt;셀렉트를 펼치면 목록이 뜨고, 그 목록이 어느 버튼에서 나왔는지 가리키려고
위쪽에 작은 삼각형 꼭지를 달았어요. 말풍선 꼬리 같은 거예요.&lt;/p&gt;
&lt;p&gt;다크에서는 잘 보였습니다. 그런데 라이트에서 사라졌어요.&lt;/p&gt;
&lt;p&gt;삼각형에 &lt;code&gt;elevation&lt;/code&gt;을 얹었습니다. 안 보였어요. 값을 키웠습니다. 그래도 안 보였어요.
토큰 사다리를 한 칸씩 올려봤습니다. &lt;code&gt;raised&lt;/code&gt;(4) → &lt;code&gt;floating&lt;/code&gt;(10) → &lt;code&gt;drawer&lt;/code&gt;(20).
아무 일도 일어나지 않았어요.&lt;/p&gt;
&lt;h1 id=&quot;그림자는-위에서-내려오는-빛을-흉내-냅니다&quot;&gt;그림자는 위에서 내려오는 빛을 흉내 냅니다&lt;/h1&gt;
&lt;p&gt;Material의 그림자 모델은 광원이 위에 있다고 가정해요.
그래서 떠 있는 물건의 그늘은 아래와 옆으로 번집니다.&lt;/p&gt;
&lt;p&gt;여기까지는 다들 아는 이야기인데, 이게 삼각형에 무슨 뜻인지가 문제였어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;      ▲          ← 이 꼭짓점의 윗면에는&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;     ╱ ╲            그늘이 질 자리가 없습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ╱   ╲&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   ─────────    ← 밑변은 판과 만나 있어서 그늘이 가려집니다&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위를 향한 삼각형의 윗면에는 그늘이 지지 않습니다. 빛이 정면으로 내려오니까요.
그리고 밑변 쪽은 판과 붙어 있어서 그늘이 보일 자리가 아니에요.&lt;/p&gt;
&lt;p&gt;그러니까 이 도형은 elevation으로 세울 수 있는 종류의 도형이 아니었습니다.
값의 문제가 아니라 방향의 문제라서, 토큰을 아무리 올려도 이 자리만은 못 살려요.&lt;/p&gt;
&lt;h1 id=&quot;라이트는-다크에서-멀쩡한-것이-사라지는-테마예요&quot;&gt;라이트는 다크에서 멀쩡한 것이 사라지는 테마예요&lt;/h1&gt;
&lt;p&gt;같은 작업에서 하나 더 배웠어요. 왜 다크에서는 보였을까요?&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;



















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;토큰&lt;/th&gt;&lt;th&gt;라이트&lt;/th&gt;&lt;th&gt;다크&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;surface&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;#FFFFFF&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;#1B1526&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;lifted&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;#FFFFFF&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;#2C233A&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;다크에서는 &lt;code&gt;lifted&lt;/code&gt;가 판보다 밝습니다.
그래서 떠 있는 것이 그늘 없이도 제 색만으로 떠올라요. 그늘은 거들 뿐이죠.&lt;/p&gt;
&lt;p&gt;라이트에서는 &lt;code&gt;lifted&lt;/code&gt;와 &lt;code&gt;surface&lt;/code&gt;가 둘 다 순백입니다.
그늘 하나가 경계를 통째로 지고 있었어요. 그늘이 안 지면 경계가 없습니다.&lt;/p&gt;
&lt;p&gt;“옅게는 다크를 보고 하는 말”이라고 적어둔 법이 색에만 해당하는 줄 알았는데,
그늘에도 그대로 맞더라고요. 같은 값이 같은 인상은 아니었습니다.&lt;/p&gt;
&lt;h1 id=&quot;그래서-실루엣을-직접-그었어요&quot;&gt;그래서 실루엣을 직접 그었어요&lt;/h1&gt;
&lt;p&gt;그늘로 못 세우면 선으로 세웁니다. 꼭지를 채운 뒤 두 빗변만 그어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;      ╱╲        ← 이 둘만 긋습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;     ╱  ╲&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ╱____╲      ← 밑변은 판과 만나는 자리라 긋지 않아요&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;밑변을 같이 그으면 삼각형이 판 위에 얹힌 스티커처럼 보입니다.
안 그으면 판에서 자라난 것처럼 붙어요.&lt;/p&gt;
&lt;p&gt;그 선의 색도 토큰(&lt;code&gt;outlineLifted&lt;/code&gt;)이고, 다크에서는 투명입니다.
다크는 애초에 이 선이 필요 없으니까요.&lt;/p&gt;
&lt;p&gt;여기서 참기 어려운 유혹이 하나 있어요. 그리는 자리에서 &lt;code&gt;if (isDark)&lt;/code&gt;를 쓰는 겁니다.
그 순간 같은 표가 두 벌이 돼요.
토큰이 테마를 알고, 그리는 쪽은 토큰만 부릅니다.&lt;/p&gt;
&lt;h1 id=&quot;같이-정한-것들&quot;&gt;같이 정한 것들&lt;/h1&gt;
&lt;h2 id=&quot;꼭지에-선이-생겼다고-판에도-두르지-않습니다&quot;&gt;꼭지에 선이 생겼다고 판에도 두르지 않습니다&lt;/h2&gt;
&lt;p&gt;흰 판 위의 중성색 실선은 “떠 있음”이 아니라 그냥 회색 테예요.
화면이 카드가 아니라 표(表)가 됩니다.&lt;/p&gt;
&lt;p&gt;우리 법의 테두리 예외는 “지금 손이 가 있는 곳”인데,
그건 잠깐 켜지는 강조지 상시 윤곽이 아니에요.&lt;/p&gt;
&lt;p&gt;꼭지의 선은 그 예외가 아니라 “그늘이 물리적으로 불가능한 자리”의 대체재입니다.
예외를 넓히지 않도록 이유를 분명히 적어뒀어요.&lt;/p&gt;
&lt;h2 id=&quot;꼭지는-모서리-곡률보다-안쪽에-둡니다&quot;&gt;꼭지는 모서리 곡률보다 안쪽에 둡니다&lt;/h2&gt;
&lt;p&gt;카드 모서리가 18dp로 둥근데 꼭지를 모서리 가까이 두면,
꼭지의 밑변 한쪽이 곡선 위에 얹힙니다.
그러면 붙은 꼭지가 아니라 낙서로 보여요.&lt;/p&gt;
&lt;h2 id=&quot;라이트는-되겠지로-넘기지-않습니다&quot;&gt;라이트는 “되겠지”로 넘기지 않습니다&lt;/h2&gt;
&lt;p&gt;다크로 개발하면 라이트는 나중에 한 번 보는 화면이 됩니다.
그런데 사라지는 쪽은 늘 라이트예요. 그러니 꼭 화면으로 봅니다.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;p&gt;여기까지 두 편은 화면에서 조용히 틀어진 것들이었어요.
다음 글은 문장이 조용히 틀어진 이야기입니다.
그것도 한 언어가 아니라 열한 언어가 똑같이요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/aapt-string-newline.html&quot;&gt;3 — aapt는 줄바꿈을 공백 하나로 접습니다 - 열한 언어가 같이 앓았어요&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>안드로이드를 만들며 1 — 비워둔 타이포 슬롯은 남의 글꼴이 들어오는 문이었어요</title>
    <link href="https://blog.twinklelabs.kr/android/material3-typography-slots.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/android/material3-typography-slots.html</id>
    <published>2026-08-22T11:10:00.000Z</published>
    <updated>2026-08-22T11:10:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Android"/>
    <category term="android"/>
    <category term="compose"/>
    <category term="design-system"/>
    <category term="typography"/>
    <summary type="html">Material3의 Typography 슬롯 열다섯 중 열하나만 정의하고 넷을 비워뒀어요. 그런데 비운 넷은 없는 칸이 되지 않았습니다. Roboto로 채워졌고, 화면은 그 넷을 실제로 부르고 있었어요.</summary>
    <content type="html">&lt;p&gt;첫 앱을 만들면서 부딪힌 것들을 여덟 편으로 나눠 적어보려고 합니다.&lt;/p&gt;
&lt;p&gt;앞의 넷은 &lt;strong&gt;빌드가 통과하고, 린트도 조용하고, 크래시도 안 났는데
화면이나 뜻이 조용히 틀어져 있던 것들&lt;/strong&gt;이에요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;비워둔 타이포 슬롯 — 지금 이 글&lt;/li&gt;
&lt;li&gt;위를 향한 삼각형은 그림자로 세울 수 없다&lt;/li&gt;
&lt;li&gt;aapt가 줄바꿈을 공백 하나로 접는다&lt;/li&gt;
&lt;li&gt;검증은 한 번, 증명은 타입이 들고 다닌다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;뒤의 넷은 그 답이 매번 같아서, 아예 구조를 바꾼 이야기입니다.&lt;/p&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;하루에 구조를 세 번 바꿨다 — Hilt · Navigation&lt;/li&gt;
&lt;li&gt;데이터를 한 줄기로 — AppRepository와 SSOT&lt;/li&gt;
&lt;li&gt;이벤트를 한 입구로 — MVI&lt;/li&gt;
&lt;li&gt;생명주기를 한 자리로 — MainActivity 다이어트&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;첫 편은 글꼴 이야기입니다.
디자인 시스템을 만들 때 아주 자연스럽게 드는 생각이 하나 있어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“안 쓰는 칸은 정의하지 말자. 쓰는 것만 두면 사다리가 깨끗하니까.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;우리가 만든 사다리에서는 맞는 말이에요. 그런데 남의 사다리에서는 정반대입니다.&lt;/p&gt;
&lt;h1 id=&quot;한-화면-안에서-글꼴이-갈려-있었어요&quot;&gt;한 화면 안에서 글꼴이 갈려 있었어요&lt;/h1&gt;
&lt;p&gt;물타기 계산기는 Pretendard 한 벌로 그립니다.
그런데 결과 카드의 3열 대시보드와 키패드의 판독값·숫자 키가 다른 글꼴로 그려지고 있었어요.&lt;/p&gt;
&lt;p&gt;같은 화면 안에서요. 제목은 Pretendard인데 그 밑의 숫자는 아니었습니다.&lt;/p&gt;
&lt;p&gt;목업의 토큰 페이지에는 이렇게 적혀 있었어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;안 서는 칸이 넷 — &lt;code&gt;displaySmall&lt;/code&gt; · &lt;code&gt;headlineSmall&lt;/code&gt; · &lt;code&gt;bodySmall&lt;/code&gt; · &lt;code&gt;titleSmall&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;사실은 서고 있었습니다. 다만 우리 글꼴이 아니었을 뿐이에요.&lt;/p&gt;
&lt;h1 id=&quot;typography의-매개변수는-전부-기본값을-가집니다&quot;&gt;&lt;code&gt;Typography()&lt;/code&gt;의 매개변수는 전부 기본값을 가집니다&lt;/h1&gt;
&lt;p&gt;Material3의 &lt;code&gt;Typography&lt;/code&gt;는 슬롯이 열다섯이에요.
우리는 그중 열하나를 정의하고 넷을 비워뒀습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 이렇게 적으면 &quot;넷은 없다&quot;가 아닙니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; TwinkleTypography &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; Typography&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    displayLarge &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; TextStyle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(fontFamily &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Pretendard, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    displayMedium &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; TextStyle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(fontFamily &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Pretendard, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // displaySmall — 안 쓰니까 뺐습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    headlineLarge &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; TextStyle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(fontFamily &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Pretendard, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    headlineMedium &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; TextStyle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(fontFamily &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; Pretendard, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;/* … */&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // headlineSmall — 안 쓰니까 뺐습니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;    // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Typography()&lt;/code&gt;의 매개변수는 전부 기본값을 가진 &lt;code&gt;TextStyle&lt;/code&gt;이에요.
안 넘기면 “비어 있음”이 넘어가는 게 아니라 Material의 값이 넘어갑니다.
그리고 Material의 기본 글꼴은 Roboto죠.&lt;/p&gt;
&lt;p&gt;즉 위 코드는 “넷을 뺐다”가 아니라 이렇게 읽힙니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;displaySmall &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt; /* Material 이 알아서 채웁니다 — Roboto 로 */&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;우리가 “지우는 일”이라고 생각한 것이 실제로는 “남더러 채우라”고 넘기는 일이었어요.&lt;/p&gt;
&lt;h1 id=&quot;왜-눈으로-못-잡았을까요&quot;&gt;왜 눈으로 못 잡았을까요&lt;/h1&gt;
&lt;p&gt;Roboto와 Pretendard는 둘 다 산세리프예요.
한글은 Roboto에 없어서 시스템 한글 글꼴로 떨어지는데, 그것도 산세리프입니다.
숫자와 라틴 문자에서만 차이가 나요.&lt;/p&gt;
&lt;p&gt;그래서 숫자가 서는 자리에서만 티가 났습니다. 대시보드와 키패드죠.
그리고 그 자리가 하필 이 앱에서 가장 자주 보는 곳이었어요.&lt;/p&gt;
&lt;p&gt;문서에 “안 서는 칸”이라고 적어둔 것도 한몫했습니다.
적어놓은 사람도, 읽은 사람도 그 문장을 확인하지 않았거든요.
문서가 틀린 게 아니라, 문서가 만든 믿음이 확인을 막은 거예요.&lt;/p&gt;
&lt;h1 id=&quot;그래서-이렇게-정했어요&quot;&gt;그래서 이렇게 정했어요&lt;/h1&gt;
&lt;h2 id=&quot;남의-프레임워크가-슬롯을-요구하면-전부-채웁니다&quot;&gt;남의 프레임워크가 슬롯을 요구하면 전부 채웁니다&lt;/h2&gt;
&lt;p&gt;안 쓰는 칸도 지우지 말고 막아둡니다.
우리 값으로 채워두면 나중에 누가 그 슬롯을 부르더라도 글꼴이 갈리지 않아요.&lt;/p&gt;
&lt;p&gt;우리 사다리의 “안 불리는 칸은 걷는다”는 법은 &lt;em&gt;우리가 만든 사다리&lt;/em&gt;에만 맞는 말이었습니다.
남이 기본값을 채워주는 자리에서 빈칸은 “안 쓴다”가 아니라 “남더러 쓰라”는 뜻이에요.&lt;/p&gt;
&lt;h2 id=&quot;사다리는-슬롯-개수와-따로-셉니다&quot;&gt;사다리는 슬롯 개수와 따로 셉니다&lt;/h2&gt;
&lt;p&gt;슬롯이 열다섯이라고 크기 사다리가 열다섯 칸일 이유는 없어요.
우리 사다리는 크기 열 칸입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;11 · 12 · 14 · 16 · 20 · 24 · 28 · 32 · 36 · 40&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 슬롯이 한 칸에 같이 서도 됩니다.
키패드의 숫자 키와 이력의 결과 숫자는 둘 다 24이고 굵기로 갈려요.&lt;/p&gt;
&lt;p&gt;슬롯은 프레임워크가 요구하는 자리 이름이고, 사다리는 우리가 정한 값입니다.
그 둘을 1:1로 맞추려고 하면 사다리에 없는 칸이 생겨요.&lt;/p&gt;
&lt;h2 id=&quot;이-칸은-아무-데도-안-선다는-문장은-화면에서-확인하고-적습니다&quot;&gt;“이 칸은 아무 데도 안 선다”는 문장은 화면에서 확인하고 적습니다&lt;/h2&gt;
&lt;p&gt;안 서는 것과 남의 값으로 서는 것은 눈으로 보면 전혀 다릅니다.
그런데 코드만 읽으면 둘 다 “없음”으로 보여요.&lt;/p&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;p&gt;이 버그의 성질이 계속 마음에 걸립니다.
빌드는 통과했고, 린트도 조용했고, 크래시도 없었어요. 그냥 글꼴이 달랐습니다.
그리고 그걸 알아채려면 그 화면을 그 자리에서 봐야 했어요.&lt;/p&gt;
&lt;p&gt;기본값을 주는 API는 편합니다.
편한 만큼 “내가 안 준 것”과 “내가 준 적 없는 값이 들어간 것”이 코드에서 똑같이 생겼어요.
그 둘을 가르는 건 문서가 아니라 화면이더라고요.&lt;/p&gt;
&lt;p&gt;다음 글도 화면에서만 보이는 이야기입니다.
이번엔 아예 눈에 안 보이던 도형 이야기예요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/android/compose-arrow-elevation.html&quot;&gt;2 — 안드로이드의 빛은 위에서 내려옵니다 - 위를 향한 꼭지는 그늘로 설 수 없어요&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>첫 앱 출시기 4 — Play Store에 올리기 - 서명, 등록정보, 콘솔에 안 가는 법</title>
    <link href="https://blog.twinklelabs.kr/stock-calculator/first-app-play-store.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/stock-calculator/first-app-play-store.html</id>
    <published>2026-08-22T02:30:00.000Z</published>
    <updated>2026-08-22T02:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Stock Calculator"/>
    <category term="android"/>
    <category term="play-store"/>
    <category term="출시기"/>
    <category term="gradle"/>
    <category term="개인개발"/>
    <summary type="html">업로드 키와 Play 앱 서명, 되돌릴 수 없는 versionCode, targetSdk 마감, 필수 고지들. 그리고 Gradle 명령 한 줄로 올리기까지 — 두 번 막혔던 권한 오류와, 스크린샷 55장이 통째로 빠졌던 이야기입니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 AdMob 세 자리와 동의(UMP), 그리고 인앱 결제를 붙였어요.
테스트 ID로 출시하거나 실제 ID로 개발하는 사고를 빌드에 맡겨 막는 이야기까지 했죠.&lt;/p&gt;
&lt;p&gt;이제 앱은 다 됐습니다. 올리기만 하면 되는데, 그 “만”이 이틀이었어요.
이번 마지막 편에서는 그 이틀에 무엇이 있었는지를 순서대로 적어볼게요.
서명부터 시작합니다.&lt;/p&gt;
&lt;h1 id=&quot;서명--키를-잃어버리면-앱을-잃습니다&quot;&gt;서명 — 키를 잃어버리면 앱을 잃습니다&lt;/h1&gt;
&lt;p&gt;가장 먼저 정할 것이 서명 방식이에요. 그리고 Play 앱 서명을 쓰는 게 맞습니다.&lt;/p&gt;
&lt;p&gt;옛날 방식은 내가 만든 키로 서명해서 올리고, 그 키를 평생 지키는 것이었어요.
잃어버리면 그 앱을 영원히 업데이트할 수 없습니다. 새 패키지명으로 새로 내야 해요.&lt;/p&gt;
&lt;p&gt;Play 앱 서명은 최종 서명키를 구글이 보관합니다.
내가 만드는 건 업로드 키 — “올릴 자격이 있다”만 증명하는 키예요.
잃어버려도 파국은 아닙니다. 콘솔에서 재설정을 요청하면 돼요(며칠 걸립니다).&lt;/p&gt;
&lt;p&gt;빌드 설정은 이렇게 해뒀습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; keystoreProperties &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; rootProject.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;file&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;keystore.properties&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (keystoreProperties.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;exists&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; props &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; Properties&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;apply&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { keystoreProperties.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;inputStream&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;use&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;load&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(it) } }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;    signingConfigs&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;        create&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;release&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;            storeFile &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; rootProject.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;file&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(props.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getProperty&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;storeFile&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;            storePassword &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; props.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;getProperty&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;storePassword&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;            …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;키와 비밀번호는 git 밖에 둡니다.
파일이 없는 기계(CI 등)에서는 서명 없이 빌드돼요. 의도한 동작입니다.
비밀번호를 저장소에 넣는 것보다 CI에서 서명을 못 하는 편이 훨씬 나으니까요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;그리고 키를 만들자마자 다른 곳에 백업해두세요.
기기 한 대에만 있는 상태로 두면, 그 기기가 죽는 순간
며칠짜리 재설정 요청부터 다시 시작해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;versioncode는-되돌릴-수-없어요&quot;&gt;versionCode는 되돌릴 수 없어요&lt;/h1&gt;
&lt;p&gt;두 번호의 성격이 완전히 다릅니다.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;



















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;무엇&lt;/th&gt;&lt;th&gt;되돌릴 수 있나&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;versionCode&lt;/code&gt;&lt;/td&gt;&lt;td&gt;콘솔이 세는 정수&lt;/td&gt;&lt;td&gt;아니오. 한 번 본 번호는 다시 안 받습니다&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;versionName&lt;/code&gt;&lt;/td&gt;&lt;td&gt;사람에게 보이는 글자&lt;/td&gt;&lt;td&gt;네. 순서도 안 따집니다&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;이걸 모르고 시험 삼아 번들 하나를 올렸다가 &lt;code&gt;versionCode = 1&lt;/code&gt;을 태워 먹었어요.
그 뒤로는 2부터 시작해야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// versionCode 1은 예전 번들이 Play 콘솔에 먹었습니다 — 콘솔은 한 번 본 코드를 다시 받지 않아요&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;versionCode &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;versionName &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; &quot;1.0.0&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반대로 &lt;code&gt;versionName&lt;/code&gt;은 자유롭습니다.
테스트 트랙에 세 번 올리면서 1.0 → 1.3까지 갔는데, 그동안 세상에 나간 건 없었어요.
공개 출시가 “1.3.0 데뷔”가 되는 건 사실과 다르니까 첫 공개판을 1.0.0으로 되돌렸습니다.
&lt;code&gt;versionCode&lt;/code&gt;만 계속 올라가요.&lt;/p&gt;
&lt;h1 id=&quot;targetsdk에는-마감이-있습니다&quot;&gt;targetSdk에는 마감이 있습니다&lt;/h1&gt;
&lt;p&gt;이건 몰랐으면 출시 직전에 막혔을 일이에요.&lt;/p&gt;
&lt;p&gt;Play는 신규 앱과 업데이트에 최신 API 타게팅을 요구하고, 그 마감이 매년 있습니다.
이 앱을 만들던 시점의 마감은 2026-08-31, 요구 수준은 API 36이었어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Chore: targetSdk 를 36 으로 올린다 — Play 마감 2026-08-31&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;targetSdk&lt;/code&gt;를 올리면 동작이 바뀌는 것들이 있어요(권한, 백그라운드 제한 등).
그래서 올린 뒤 한 번 다 돌려봐야 합니다. 출시 전날 할 일이 아니에요.&lt;/p&gt;
&lt;h1 id=&quot;코드-말고도-준비할-게-많아요&quot;&gt;코드 말고도 준비할 게 많아요&lt;/h1&gt;
&lt;p&gt;콘솔이 요구하는 것 중 코드가 아닌 것들입니다.&lt;/p&gt;
&lt;p&gt;개인정보 처리방침 URL — 웹에 실제로 떠 있어야 해요.
“아무것도 수집하지 않는다”도 적어서 올려야 합니다. 안 적으면 없는 것으로 안 봐줘요.&lt;/p&gt;
&lt;p&gt;데이터 보안 양식 — 무엇을 수집하고 어디로 보내는지 콘솔에서 선언합니다.
광고 SDK가 들어가면 “없음”이 아니게 돼요. 여기서 거짓을 적으면 나중에 앱이 내려갑니다.&lt;/p&gt;
&lt;p&gt;앱 콘텐츠 설문 — 광고 포함 여부, 타겟 연령층, 콘텐츠 등급.&lt;/p&gt;
&lt;p&gt;그리고 저희 경우엔 필수 고지가 둘 있었어요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;오픈소스 라이선스 — 이 앱은 Pretendard 글꼴을 씁니다. SIL OFL 1.1이라
고지 없이 배포하면 위반이에요. 설정에 라이선스 화면을 넣었습니다.&lt;/li&gt;
&lt;li&gt;투자 면책 — 금융 계산 앱이니 “계산 도구이지 투자 자문이 아니다”를 밝힙니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 둘은 설정 화면에서 있으면 좋은 것이 아니라 의무라서, 지금은 테스트가 지키고 있어요.&lt;/p&gt;
&lt;h1 id=&quot;등록정보와-스크린샷&quot;&gt;등록정보와 스크린샷&lt;/h1&gt;
&lt;p&gt;제목, 짧은 설명, 긴 설명, 스크린샷, 아이콘 512×512, 피처 그래픽 1024×500.
언어를 늘렸으니 이걸 로케일마다 준비했습니다. 11개 언어 × 스크린샷 5장이에요.&lt;/p&gt;
&lt;p&gt;스크린샷은 손으로 찍지 않고 에뮬레이터에서 자동으로 뽑았어요.
좌표를 못 박으면 언어마다 글자 길이가 달라 버튼 위치가 밀리거든요.
그래서 &lt;code&gt;uiautomator&lt;/code&gt;로 요소를 찾아서 누르게 했습니다.
그래야 독일어 화면에서도 같은 자리가 찍혀요.&lt;/p&gt;
&lt;h1 id=&quot;콘솔에-안-가고-올리기&quot;&gt;콘솔에 안 가고 올리기&lt;/h1&gt;
&lt;p&gt;여기서 삶이 편해졌어요. Gradle Play Publisher 플러그인을 쓰면 명령 한 줄로 올라갑니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;./gradlew publishReleaseBundle                       # 내부 테스트&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;./gradlew publishReleaseBundle --track production     # 프로덕션&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;./gradlew publishReleaseListing                       # 등록정보·스크린샷&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;준비물은 서비스 계정 키 하나예요(Google Cloud에서 만들어 콘솔에 권한을 줍니다).
그리고 여기서 두 번 막혔습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;accessNotConfigured&lt;/code&gt; — Cloud 프로젝트에서 &lt;em&gt;Google Play Android Developer API&lt;/em&gt;를
켜야 해요. 무료고, 켜는 데 1분입니다. 안 켜져 있다는 걸 아는 데 30분이 걸렸어요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;The caller does not have permission&lt;/code&gt; — Play 콘솔의 “사용자 및 권한”에서
서비스 계정에 앱 접근 + 출시 권한을 줘야 합니다.
Cloud 쪽 IAM 역할을 아무리 만져도 안 돼요. 권한은 Play 콘솔 쪽에 있습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;둘 다 에러 메시지가 “키가 잘못됐다”처럼 읽혀서 키를 몇 번이나 다시 만들었어요.
PERMISSION_DENIED가 뜨면 키가 아니라 위 두 곳을 보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;그리고-스크린샷-55장이-통째로-빠져-있었어요&quot;&gt;그리고 스크린샷 55장이 통째로 빠져 있었어요&lt;/h1&gt;
&lt;p&gt;이 글을 쓰다가 발견한 건데, 시리즈에 넣을 만한 종류라 적어둡니다.&lt;/p&gt;
&lt;p&gt;11개 로케일 등록정보를 만들어 넣었는데 폴더 위치가 틀렸어요.
플러그인이 읽는 자리는 &lt;code&gt;app/src/main/play/&lt;/code&gt;인데 &lt;code&gt;app/play/&lt;/code&gt;에 만들었더라고요.&lt;/p&gt;
&lt;p&gt;그래서 실제로 올라가는 건 옛 한국어 등록정보 하나뿐이었습니다.
스크린샷 3장에, 옛 문구로요.&lt;/p&gt;
&lt;p&gt;이런 종류가 제일 나쁩니다. 빌드도 성공하고 업로드도 성공하거든요.
무엇이 빠졌는지는 스토어를 열어봐야 알고, 그때는 이미 올라간 뒤예요.&lt;/p&gt;
&lt;p&gt;올리기 전에 플러그인이 무엇을 모았는지 먼저 보는 습관을 들이는 게 훨씬 쌉니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;./gradlew generateReleasePlayResources&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;# → app/build/generated/gpp/release/play/ 아래에 실제로 올라갈 것이 모입니다&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id=&quot;트랙--한-번에-전체-공개로-가지-않습니다&quot;&gt;트랙 — 한 번에 전체 공개로 가지 않습니다&lt;/h1&gt;
&lt;p&gt;Play에는 트랙이 여러 개 있어요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;내부 테스트 — 최대 100명, 심사 거의 없이 바로. 개발 중엔 여기만 씁니다&lt;/li&gt;
&lt;li&gt;비공개 테스트 — 지정한 테스터&lt;/li&gt;
&lt;li&gt;공개 테스트 — 누구나 참여&lt;/li&gt;
&lt;li&gt;프로덕션 — 전체 공개&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;기본 트랙을 내부 테스트로 박아뒀어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;play&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    track.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;internal&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    defaultToAppBundles.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    releaseStatus.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(ReleaseStatus.COMPLETED)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--track production&lt;/code&gt;을 명시해야만 프로덕션으로 갑니다.
실수로 전체 공개되는 것보다 매번 한 단어 더 치는 게 나아요.&lt;/p&gt;
&lt;h2 id=&quot;고치고-나서-적어-둔-것&quot;&gt;고치고 나서 적어 둔 것&lt;/h2&gt;
&lt;p&gt;솔직히 적으면, 이 시리즈를 쓰는 지금 앱은 아직 테스트 트랙에 있어요.
프로덕션 승격은 안 했습니다.&lt;/p&gt;
&lt;p&gt;그런데도 이 네 편을 쓴 이유는, “빈손에서 스토어 콘솔까지” 사이에 있는 것들이
검색으로 잘 안 나왔기 때문이에요.
대부분의 글이 코드만 다루거나 콘솔 스크린샷만 붙여놓는데,
실제로 제 시간을 잡아먹은 건 그 사이의 것들이었습니다.&lt;/p&gt;
&lt;p&gt;어떤 키를 먼저 만들어야 하는지, 어떤 번호가 되돌릴 수 없는지,
어떤 에러 메시지가 사실은 다른 곳을 가리키는지 같은 것들이요.&lt;/p&gt;
&lt;p&gt;2주 동안 배운 걸 한 줄로 줄이면 이렇습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;만드는 데 열흘, 올리는 데 이틀.
그런데 그 이틀이 열흘만큼 깁니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;다음 글은 실제로 프로덕션에 올린 뒤에 쓸게요.
그전까지는 앱 안쪽에서 배운 것들을 &lt;a href=&quot;/category/android/&quot;&gt;안드로이드를 만들며 배운 것&lt;/a&gt; 시리즈에 적어두고 있습니다.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>첫 앱 출시기 3 — AdMob 붙이기 - 광고 세 종류와 동의, 계정 정지를 피하는 법</title>
    <link href="https://blog.twinklelabs.kr/stock-calculator/first-app-admob.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/stock-calculator/first-app-admob.html</id>
    <published>2026-08-22T02:00:00.000Z</published>
    <updated>2026-08-22T02:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Stock Calculator"/>
    <category term="android"/>
    <category term="admob"/>
    <category term="출시기"/>
    <category term="수익화"/>
    <category term="billing"/>
    <summary type="html">앱 ID를 매니페스트에 넣는 것부터 EEA 동의 폼까지. 배너·전면·보상형 세 자리를 붙이면서 실제로 막혔던 곳들과, 테스트 광고를 실수로 출시하지 않기 위해 빌드에 맡긴 장치를 이야기합니다.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 HTML 목업 한 장으로 시작한 설계 과정을 이야기했어요.
목업에서 합의하고 앱으로 옮기는 사이클, 그리고 목업의 역할은 빠른 시작이었으니
그 시작이 끝났을 때 놓아줬다는 이야기까지요.&lt;/p&gt;
&lt;p&gt;여기까지가 만드는 이야기였고, 이번 글부터는 절차를 통과하는 이야기입니다.
그 첫 번째가 광고예요.&lt;/p&gt;
&lt;p&gt;앱을 다 만들고 나면 “이제 광고만 붙이면 되겠네” 싶은데, 그게 하루가 걸립니다.
코드가 어려워서가 아니라 콘솔을 오가며 값을 만들어 오는 일이 많아서예요.&lt;/p&gt;
&lt;h1 id=&quot;순서--무엇을-먼저-만들어야-하나&quot;&gt;순서 — 무엇을 먼저 만들어야 하나&lt;/h1&gt;
&lt;p&gt;AdMob은 값을 두 층으로 줍니다. 이걸 헷갈리면 계속 막혀요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;앱 ID (&lt;code&gt;ca-app-pub-…~…&lt;/code&gt;) — 앱 하나에 하나. 물결(&lt;code&gt;~&lt;/code&gt;)이 들어갑니다&lt;/li&gt;
&lt;li&gt;광고 단위 ID (&lt;code&gt;ca-app-pub-…/…&lt;/code&gt;) — 광고 자리마다 하나. 빗금(&lt;code&gt;/&lt;/code&gt;)이 들어갑니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;앱 ID는 매니페스트에 박고, 단위 ID는 코드에서 씁니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#F7768E&quot;&gt;meta-data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;    android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;com.google.android.gms.ads.APPLICATION_ID&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;    android&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-dark:#BB9AF7&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#DE5971&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;ca-app-pub-…~…&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#89DDFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BA3C97&quot;&gt; /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;이 값을 빠뜨리거나 틀리게 넣으면 앱이 뜨자마자 죽습니다.
SDK가 ID 불일치로 예외를 던지거든요.
“광고가 안 나온다”가 아니라 “앱이 안 켜진다”라서 처음엔 광고 문제인 줄도 몰랐어요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;광고를-세-자리에-넣었어요&quot;&gt;광고를 세 자리에 넣었어요&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;배너 — 바텀바 위에 한 줄&lt;/li&gt;
&lt;li&gt;전면 — 계산을 저장한 뒤에, 세 번에 한 번&lt;/li&gt;
&lt;li&gt;보상형 — 무료 종목 3개를 넘겨 만들 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;각각 AdMob 콘솔에서 광고 단위를 따로 만들어야 합니다.
그리고 여기서 한 번 밟았어요. 보상형과 보상형 전면(베타)은 서로 다른 형식입니다.&lt;/p&gt;
&lt;p&gt;콘솔에서 어느 카드로 만들었는지가 곧 코드에서 쓸 클래스를 정해요.
잘못 만들면 SDK가 로드를 거절하는데, 에러 메시지가 그걸 친절히 말해주지 않습니다.&lt;/p&gt;
&lt;h1 id=&quot;테스트-id로-출시하거나-실제-id로-개발하거나&quot;&gt;테스트 ID로 출시하거나, 실제 ID로 개발하거나&lt;/h1&gt;
&lt;p&gt;이 실수는 방향이 반대인 두 가지인데 둘 다 치명적이에요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테스트 ID인 채로 출시 → 수익이 0&lt;/li&gt;
&lt;li&gt;실제 ID로 개발 기기에서 눌러댐 → 계정 정지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;사람의 기억에 맡길 일이 아니라서 빌드에 맡겼습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; AdUnits&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; TEST_BANNER &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; &quot;ca-app-pub-3940256099942544/9214589741&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; BANNER &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (BuildConfig.DEBUG) TEST_BANNER&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;                 else&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt; &quot;ca-app-pub-…/…&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;   // 실제 단위&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;디버그 빌드는 언제나 구글 공식 테스트 단위, 릴리스는 실제 단위.
앱 ID(매니페스트)는 양쪽 다 실제 것을 씁니다.
구글이 권하는 조합이 “실제 앱 ID + 테스트 광고 단위”거든요.&lt;/p&gt;
&lt;h1 id=&quot;동의가-먼저입니다-ump&quot;&gt;동의가 먼저입니다 (UMP)&lt;/h1&gt;
&lt;p&gt;이게 제일 몰랐던 부분이에요.&lt;/p&gt;
&lt;p&gt;EEA·영국·스위스 사용자에게 광고를 내보내려면 인증된 동의 플랫폼(CMP)이 필요하고,
동의가 서기 전에 광고 요청을 열면 정책 위반입니다.
다행히 구글이 UMP(User Messaging Platform) SDK를 무료로 줍니다.&lt;/p&gt;
&lt;p&gt;핵심은 “폼을 모두에게 보여주기”가 아니라 “물어야 하는 사람에게만 묻기”예요.
한국 사용자에게는 폼이 아예 안 뜹니다.
UMP가 “동의 불필요 지역”이라고 답하는 즉시 광고를 열 수 있어요.&lt;/p&gt;
&lt;p&gt;그래서 광고 세 자리가 전부 문 하나를 봅니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;object&lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt; ConsentGate&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    var&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; canRequestAds &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;by&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; mutableStateOf&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt;        private&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-light-font-style:inherit;--shiki-dark:#9D7CD8;--shiki-dark-font-style:italic&quot;&gt; set&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;배너도 전면도 보상형도 요청 전에 이 값을 확인해요.
문이 여러 개면 그중 하나는 반드시 잠그는 걸 잊게 되더라고요.&lt;/p&gt;
&lt;p&gt;그리고 동의를 되물릴 권리가 있는 지역에서는 설정에 “광고 개인정보 선택” 줄이 서야 합니다.
이것도 CMP 요구사항이에요.
반대로 물은 적 없는 지역에는 그 줄을 세우지 않습니다. 무엇을 되묻는지 알 수 없으니까요.&lt;/p&gt;
&lt;h1 id=&quot;광고-자리를-정할-때-지킨-것&quot;&gt;광고 자리를 정할 때 지킨 것&lt;/h1&gt;
&lt;p&gt;정책 위반이기 전에 나쁜 앱이 되는 것들이 있어요.&lt;/p&gt;
&lt;h2 id=&quot;높이를-미리-잡아둡니다&quot;&gt;높이를 미리 잡아둡니다&lt;/h2&gt;
&lt;p&gt;광고가 도착하는 순간 화면이 밀려 내려가면,
사용자가 막 누르려던 것이 광고 자리로 미끄러져 들어옵니다.
그래서 광고가 오기 전에도 그 높이만큼 자리를 비워둬요.&lt;/p&gt;
&lt;h2 id=&quot;숫자-치는-손-옆에-광고를-안-세웁니다&quot;&gt;숫자 치는 손 옆에 광고를 안 세웁니다&lt;/h2&gt;
&lt;p&gt;키패드가 올라오면 배너도 함께 비킵니다.
오터치가 수익이 되는 구조는 만들면 안 돼요. 그 수익은 받으면 안 되는 돈입니다.&lt;/p&gt;
&lt;h2 id=&quot;전면은-저장-뒤에만-띄웁니다&quot;&gt;전면은 저장 뒤에만 띄웁니다&lt;/h2&gt;
&lt;p&gt;저장은 이미 끝난 행동이라 광고가 일을 막지 않아요.
계산 도중에 끼어들면 화면의 흐름이 두 번 끊깁니다.&lt;/p&gt;
&lt;p&gt;그리고 미리 로드돼 있을 때만 보여줍니다.
광고를 기다리게 하는 순간 “저장”이 느린 버튼이 되거든요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;fun&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt; onSaved&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(activity: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#DF8E1D;--shiki-light-font-style:italic;--shiki-dark:#C0CAF5;--shiki-dark-font-style:inherit&quot;&gt;Activity&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    saves &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; (saves &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; everyN &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;) { &lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;preload&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(activity); &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;    val&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; ad &lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; loaded ?: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;run&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;preload&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(activity); &lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#BB9AF7&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt; }   &lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 준비 안 됐으면 그냥 보냅니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;    …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;광고는-한-번-만들어-앱이-사는-동안-씁니다&quot;&gt;광고는 한 번 만들어 앱이 사는 동안 씁니다&lt;/h2&gt;
&lt;p&gt;배너를 컴포저블 안에서 만들었더니 키패드를 여닫을 때마다 &lt;code&gt;AdView&lt;/code&gt;가 파괴되고 다시 만들어졌어요.
그때마다 새 요청이 나갔습니다.&lt;/p&gt;
&lt;p&gt;요청이 잦으면 채워주지 않고(실제로 빈 배너가 됐어요),
잦은 요청 자체가 무효 트래픽으로 읽힙니다.&lt;/p&gt;
&lt;h1 id=&quot;광고-제거를-파는-것도-결국-광고-일이에요&quot;&gt;광고 제거를 파는 것도 결국 광고 일이에요&lt;/h1&gt;
&lt;p&gt;“광고 빼고 싶으면 결제해주세요”를 넣었습니다. 인앱 결제(Play Billing)예요.&lt;/p&gt;
&lt;p&gt;파는 것은 비소모성 상품 하나입니다.
한 번 사면 영원히 광고가 사라지고, 앱을 지웠다 깔아도 구글 계정에 구매가 남아요.
그래서 앱이 뜰 때마다 소유를 다시 확인해서 돌려줍니다.
복원 버튼을 따로 안 만든 이유가 이거예요.&lt;/p&gt;
&lt;p&gt;여기서 꼭 알아야 할 게 하나 있어요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;확인(acknowledge)을 잊으면 구글이 3일 뒤 자동 환불합니다.
돈은 받았는데 사흘 뒤 사라지고, 사용자는 “샀는데 광고가 나온다”고 문의해요.
그래서 소유를 볼 때마다 미확인 구매에는 즉시 확인 도장을 찍습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;그리고 이미 산 사람에게는 광고 제거 줄을 아예 안 세웁니다.
산 것을 또 팔면 “내가 산 게 맞나”부터 의심하게 되고,
그 의심은 환불 문의로 돌아오더라고요.&lt;/p&gt;
&lt;h1 id=&quot;에뮬레이터에서-광고가-안-뜬다면&quot;&gt;에뮬레이터에서 광고가 안 뜬다면&lt;/h1&gt;
&lt;p&gt;마지막으로, 반나절을 날린 함정 하나 알려드릴게요.&lt;/p&gt;
&lt;p&gt;기본으로 띄운 안드로이드 에뮬레이터는 &lt;code&gt;googleads.g.doubleclick.net&lt;/code&gt;을 해석하지 못합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Unable to resolve host … No address associated with hostname&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Ad failed to load : 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;배너 자리는 잡히는데 광고만 안 채워지니 앱 버그처럼 보여요.
DNS를 직접 주고 띄우면 테스트 광고가 정상으로 뜹니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;emulator -avd &amp;#x3C;이름&gt; -dns-server 8.8.8.8,1.1.1.1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;p&gt;여기까지 하면 광고가 붙은 앱이 손에 있습니다.
이제 이걸 스토어에 올려야 하는데, 그게 또 다른 이야기예요.&lt;/p&gt;
&lt;p&gt;마지막 편에서는 서명 키, 되돌릴 수 없는 versionCode, 필수 고지, 그리고
콘솔에 안 들어가고 명령 한 줄로 올리는 방법을 이야기할게요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/stock-calculator/first-app-play-store.html&quot;&gt;4 — Play Store에 올리기 - 서명, 등록정보, 그리고 콘솔에 안 가는 법&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>첫 앱 출시기 2 — Figma 대신 HTML 목업으로 시작했고, 나중에 놓아줬어요</title>
    <link href="https://blog.twinklelabs.kr/stock-calculator/first-app-design-cycle.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/stock-calculator/first-app-design-cycle.html</id>
    <published>2026-08-22T01:30:00.000Z</published>
    <updated>2026-08-22T01:30:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Stock Calculator"/>
    <category term="android"/>
    <category term="출시기"/>
    <category term="design-system"/>
    <category term="개인개발"/>
    <summary type="html">코드보다 목업이 먼저였는데, 그게 Figma가 아니라 HTML 한 장이었어요. 목업의 역할은 처음부터 빠른 시작이었습니다. 그 역할이 끝나는 자리를 어떻게 알아봤고, 어떻게 놓아줬는지까지 적어요.</summary>
    <content type="html">&lt;p&gt;지난 글에서는 무엇을 만들었고 무엇을 안 만들었는지를 이야기했어요.
시세 연동도, 자산 관리도, 서버도 없이 질문 두 개에만 답하는 앱으로 범위를 좁힌 이야기였죠.&lt;/p&gt;
&lt;p&gt;이번 글은 어떤 순서로 만들었나입니다.
먼저 결과부터 적으면, 코드보다 목업이 먼저였고, 그 목업이 Figma가 아니라 HTML 한 장이었어요.
왜 그랬는지, 그리고 혼자 만들 때 생기는 가장 큰 문제를 어떻게 풀었는지 적어볼게요.&lt;/p&gt;
&lt;h1 id=&quot;첫-커밋에-앱과-목업이-같이-들어갔어요&quot;&gt;첫 커밋에 앱과 목업이 같이 들어갔어요&lt;/h1&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Initial commit: add stock calculator app and design mockup&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;저장소 구조가 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;stock-calculator/&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;├── design/    ← 화면 설계 + 디자인 토큰 (mockup.html)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;└── app/       ← 안드로이드 소스&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;design/mockup.html&lt;/code&gt; 한 장에 모든 화면이 들어 있어요.
계산기, 이력, 설정, 시트, 확인창, 키패드 서랍까지요. 열면 브라우저에서 바로 돕니다.&lt;/p&gt;
&lt;h1 id=&quot;왜-figma가-아니었을까요&quot;&gt;왜 Figma가 아니었을까요&lt;/h1&gt;
&lt;p&gt;이유가 셋 있었어요.&lt;/p&gt;
&lt;h2 id=&quot;하나--토큰이-곧-코드예요&quot;&gt;하나 — 토큰이 곧 코드예요&lt;/h2&gt;
&lt;p&gt;목업의 &lt;code&gt;:root&lt;/code&gt;에 CSS 변수로 색·간격·모서리·움직임을 정의해두면,
그게 그대로 앱의 토큰 표가 됩니다. 디자인 툴에서 코드로 옮기는 “번역” 단계가 아예 없어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#E0AF68&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#BB9AF7&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --s1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 8&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#F7768E&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;   --s1h&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 12&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#F7768E&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --s2&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 16&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#F7768E&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --radius-card&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt; 18&lt;/span&gt;&lt;span style=&quot;--shiki-light:#8839EF;--shiki-dark:#F7768E&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --ease&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#0DB9D7;--shiki-dark-font-style:inherit&quot;&gt; cubic-bezier&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;.25&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;.5&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-dark:#C0CAF5&quot;&gt;  --exit&lt;/span&gt;&lt;span style=&quot;--shiki-light:#179299;--shiki-dark:#89DDFF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#0DB9D7;--shiki-dark-font-style:inherit&quot;&gt; cubic-bezier&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;.4&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;--shiki-light:#FE640B;--shiki-dark:#FF9E64&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#89DDFF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#9ABDF5&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;둘--움직임을-진짜로-볼-수-있어요&quot;&gt;둘 — 움직임을 진짜로 볼 수 있어요&lt;/h2&gt;
&lt;p&gt;시트가 올라오는 속도, 키패드가 닫히는 곡선.
이건 정지 화면으로는 판단이 안 됩니다.
브라우저에서 실제로 열고 닫아봐야 “이거 왜 이렇게 굼뜨지?”가 보여요.&lt;/p&gt;
&lt;h2 id=&quot;셋--반응형을-바로-잴-수-있어요&quot;&gt;셋 — 반응형을 바로 잴 수 있어요&lt;/h2&gt;
&lt;p&gt;폭을 못 박은 iframe 안에 목업을 띄우고 &lt;code&gt;scrollWidth&lt;/code&gt;와 &lt;code&gt;clientWidth&lt;/code&gt;를 비교하면
가로 스크롤이 나는지 바로 압니다. 디자인 툴에서는 좁은 화면 프레임을 따로 그려야 하죠.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;여기서 한 번 크게 헤맸어요. 헤드리스 브라우저로 스크린샷만 찍어서 판단하려고 했거든요.
&lt;code&gt;--window-size=390&lt;/code&gt;을 줘도 스크린샷만 390px로 잘리고 레이아웃은 489px로 그려집니다.
오른쪽이 잘린 걸 보고 “가로 스크롤 버그다”라며 없는 버그를 고칠 뻔했어요.
스크린샷은 예쁜지를 보는 도구이지, 넘치는지를 재는 도구가 아니었습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id=&quot;혼자-만들-때의-진짜-문제--합의할-상대가-없어요&quot;&gt;혼자 만들 때의 진짜 문제 — 합의할 상대가 없어요&lt;/h1&gt;
&lt;p&gt;회사에서라면 디자이너와 개발자가 목업을 놓고 다툽니다. 그리고 그 다툼이 결정을 남기죠.&lt;/p&gt;
&lt;p&gt;혼자면 그게 없어요. 그래서 결정과 그 이유를 글로 남기는 자리를 따로 만들었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;저장소 루트의 원칙 문서 — 브랜드·디자인 시스템·엔지니어링 원칙&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CHANGELOG.md&lt;/code&gt; — 판올림마다 “사용자에게”와 “안에서”를 나눠서&lt;/li&gt;
&lt;li&gt;기록용 금고 — 판단의 이유, 배운 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 CHANGELOG에 되돌림 항목을 따로 둔 게 크게 도움이 됐어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;- 되돌림: 통화 기호를 «원» 접미사로 시작했다가 ₩ 접두사로&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  — 일곱 언어가 생긴 순간 «원»은 한국어를 읽는 사람에게만 통하는 그림이 된다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;- 되돌림: 이름 입력 시트의 자동 포커스&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  — 얼굴 피커와 함께 서면서 자판이 시트를 가린 채 튀어서&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;되돌린 결정을 지워버리면 다음 사람(대개 미래의 나)이 같은 자리를 두 번 팝니다.
실제로 “연보라를 걷어내자”를 두 번 시도했다가,
기록을 보고 왜 되돌렸는지 알고 멈춘 적이 있어요.&lt;/p&gt;
&lt;h1 id=&quot;사이클--목업에서-합의하고-앱으로-옮기고-다시-목업&quot;&gt;사이클 — 목업에서 합의하고, 앱으로 옮기고, 다시 목업&lt;/h1&gt;
&lt;p&gt;이 프로젝트의 리듬은 이랬습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;목업에서 먼저 바꿉니다. 색이든 간격이든 배치든요.&lt;/li&gt;
&lt;li&gt;브라우저에서 열어보고 판단합니다. 아니다 싶으면 여기서 끝. 코드는 안 건드려요.&lt;/li&gt;
&lt;li&gt;좋으면 앱으로 옮깁니다.&lt;/li&gt;
&lt;li&gt;앱에서 새로 알게 된 것은 목업에도 반영합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;4번이 중요했어요. 목업과 앱이 갈라지기 시작하면 목업은 그냥 낡은 그림이 되고,
그때부터는 아무도 안 보게 되거든요. 실제로 커밋 메시지에 이런 게 여러 개 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: 목업 키패드 서랍·이력 머리줄 2단·길게 누름 시트 영수증 구조를 앱과 한 벌로 정렬&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;미리 말씀드리면, 나중에는 이 4번을 놓았어요.
목업이 낡아서가 아니라 제 몫을 다 해서였는데, 그 이야기는 이 글 끝에서 하겠습니다.&lt;/p&gt;
&lt;h1 id=&quot;빨리-만들고-계속-고친다가-아니라-빨리-만들고-계속-되돌린다&quot;&gt;빨리 만들고 계속 고친다가 아니라, 빨리 만들고 계속 되돌린다&lt;/h1&gt;
&lt;p&gt;2주 중 마지막 사흘은 거의 전부 디자인 정리였어요. 커밋 제목만 봐도 보입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: 연보라 걷어내기 — sunken을 primarySoft와 갈라놓고…&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: sunken을 #F5F4F8로 한 칸 더 밝게&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: 홈 세그먼트 트랙을 흰 면(surface)으로&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: 바탕 위에 서는 것들을 흰 면으로&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;같은 자리를 네 번 만졌습니다. 처음엔 이게 낭비처럼 느껴졌는데,
지나고 보니 한 번에 맞출 수 있는 종류가 아니었어요.&lt;/p&gt;
&lt;p&gt;연보라를 걷으니 파인 면이 회색으로 떴고, 그걸 밝히니 세그먼트 트랙이 애매해졌습니다.
색은 혼자 서지 않고 옆의 색과 함께 서더라고요.&lt;/p&gt;
&lt;p&gt;기능도 마찬가지였어요. 키패드의 “바깥 누르면 닫기”는 세 번 만에 맞췄습니다.
투명 층을 위에 덮었다가, 아래에 깔았다가, 결국 아무도 안 가져간 탭을 듣는 방식으로요.&lt;/p&gt;
&lt;h1 id=&quot;처음부터-열한-언어를-노리지-않았어요&quot;&gt;처음부터 열한 언어를 노리지 않았어요&lt;/h1&gt;
&lt;p&gt;8월 18일에 영어가 들어갔고, 8월 22일에 아홉 개가 더 들어갔습니다.
한국어만으로 먼저 완성한 다음 언어를 늘렸어요.&lt;/p&gt;
&lt;p&gt;이 순서가 맞았습니다. 한국어 하나로 화면이 정리되기 전에 열한 언어를 얹었으면
문구를 고칠 때마다 열한 번씩 고쳐야 했을 거예요.&lt;/p&gt;
&lt;p&gt;대신 처음부터 지킨 규칙이 하나 있어요.
코드에 문자열 리터럴을 심지 않는다. 서식까지 포함해서요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-light-font-style:italic;--shiki-dark:#51597D;--shiki-dark-font-style:italic&quot;&gt;// 이렇게 쓰는 순간 그 화면은 한국어 전용이 됩니다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#1E66F5;--shiki-light-font-style:italic;--shiki-dark:#7AA2F7;--shiki-dark-font-style:inherit&quot;&gt;Text&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#7DCFFF&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;amount&lt;/span&gt;&lt;span style=&quot;--shiki-light:#7C7F93;--shiki-dark:#7DCFFF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;--shiki-light:#40A02B;--shiki-dark:#9ECE6A&quot;&gt;원&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#4C4F69;--shiki-dark:#A9B1D6&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;날짜 서식도, &lt;code&gt;%1$s주&lt;/code&gt; 같은 단위도, 심지어 통화 이름 옆의 괄호까지
전부 문자열 리소스에 뒀습니다.&lt;/p&gt;
&lt;p&gt;일본어와 중국어는 전각 괄호（）를 쓰고 그 앞에 공백을 두지 않아요.
그런데 코드에서 &lt;code&gt;&quot; (&quot; + symbol + &quot;)&quot;&lt;/code&gt;로 이어 붙이면 그 화면은 반각 괄호 언어 전용이 됩니다.&lt;/p&gt;
&lt;p&gt;이 규칙 하나 덕분에 나중에 언어 아홉 개를 얹는 게 “번역만 하면 되는 일”이 됐어요.&lt;/p&gt;
&lt;h1 id=&quot;그리고-나중에-목업을-놓아줬어요&quot;&gt;그리고 나중에 목업을 놓아줬어요&lt;/h1&gt;
&lt;p&gt;이 글을 여기서 끝내면 “HTML 목업 좋아요”로 읽힐 텐데, 그건 절반만 맞아요.&lt;/p&gt;
&lt;p&gt;목업 동기화는 2026년 8월 22일에 멈췄습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Design: 목업 시트 동기화 마무리 — 확인창 부제·선택지 두 줄, 종목 삭제/이력 삭제 문구를 앱과 한 벌로&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 커밋이 마지막이에요. 그 뒤로 앱은 계속 갔는데 목업은 그 자리에 있습니다.
그리고 그건 사고가 아니라 놓아준 것이었어요.&lt;/p&gt;
&lt;h2 id=&quot;목업의-역할은-빠른-시작이에요&quot;&gt;목업의 역할은 빠른 시작이에요&lt;/h2&gt;
&lt;p&gt;돌아보니 제가 목업에게 시킨 일은 처음부터 하나였습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;빨리 시작하게 해줄 것.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;앱이 아직 없던 2주 전에는 그게 정말 컸어요.
Compose를 다시 빌드하며 색을 고르는 것보다 브라우저에서 새로고침하는 게 훨씬 빨랐고,
아니다 싶으면 코드를 한 줄도 안 건드리고 되돌릴 수 있었으니까요.&lt;/p&gt;
&lt;p&gt;무엇보다 그때 목업의 &lt;code&gt;:root&lt;/code&gt;에 적은 값들이 그대로
&lt;a href=&quot;/design-system/aurora-ledger.html&quot;&gt;Aurora Ledger&lt;/a&gt;라는 디자인 시스템이 됐습니다.&lt;/p&gt;
&lt;p&gt;그러니까 목업이 한 일은 화면을 그리는 것이 아니라 값에 이름을 붙이는 것이었어요.
그리고 그 일에는 끝이 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;시작이-끝나면-역할도-끝나요&quot;&gt;시작이 끝나면 역할도 끝나요&lt;/h2&gt;
&lt;p&gt;빠른 시작이 역할이라면, 앱이 서고 나면 그 역할은 자연히 작아집니다.
이제는 앱을 켜는 게 제일 빠르니까요.&lt;/p&gt;
&lt;p&gt;그런데 문제는 역할이 작아지는 만큼 비용은 커진다는 것이었어요.
앱이 앱다워질수록 목업이 흉내 낼 수 없는 것들이 늘어났거든요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;열한 개 언어에서 글자 길이가 달라지며 버튼이 밀리는 것&lt;/li&gt;
&lt;li&gt;자판이 올라오며 시트를 가리는 것&lt;/li&gt;
&lt;li&gt;실제 기기의 스크롤 관성과 길게 누름의 타이밍&lt;/li&gt;
&lt;li&gt;시스템 폰트 크기 설정을 키웠을 때 무너지는 자리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이걸 HTML로 재현하려면 목업이 앱을 흉내 내는 일을 해야 합니다.
빨리 시작하라고 만든 물건에게 앱 흉내를 시키는 거죠.
그 순간 목업은 앞서가는 자리가 아니라 따라오는 자리가 됩니다.&lt;/p&gt;
&lt;h2 id=&quot;뒤처진-쪽은-늘-정해져-있었어요&quot;&gt;뒤처진 쪽은 늘 정해져 있었어요&lt;/h2&gt;
&lt;p&gt;8월 22일에 이런 걸 발견했습니다.
안내 시트에서 “그대로입니다”가 “그대로입니 / 다”로 잘려 있었어요.&lt;/p&gt;
&lt;p&gt;한글은 낱말 안에서 끊지 않는다는 게 우리 법이고,
목업 CSS에는 &lt;code&gt;keep-all&lt;/code&gt;이 들어 있었습니다. 그런데 앱에는 옮겨 온 적이 없었어요.&lt;/p&gt;
&lt;p&gt;여기서 중요한 건 어느 쪽이 틀렸느냐가 아니라 어느 쪽을 사용자가 여느냐였습니다.
목업이 맞고 앱이 틀렸는데, 사용자가 여는 건 앱이에요.&lt;/p&gt;
&lt;p&gt;이런 어긋남을 몇 번 겪고 나니 알겠더라고요.
목업이 앞서던 동안에는 목업이 진실이었는데, 앱이 앞서기 시작한 순간부터는
목업이 맞는 것조차 도움이 안 됩니다. 고쳐야 하는 건 늘 앱 쪽이니까요.&lt;/p&gt;
&lt;p&gt;두 벌을 맞추는 일이 그때부터 순수한 비용이 됐어요.
&lt;a href=&quot;/design-system/light-dark-one-copy.html&quot;&gt;디자인 시스템 세우기 1&lt;/a&gt;에서 색 표가 여섯 벌이 됐던 것과
같은 병이고, 이번엔 그 두 벌이 CSS와 Kotlin이었을 뿐입니다.&lt;/p&gt;
&lt;h2 id=&quot;놓아주는-것과-버리는-것은-달라요&quot;&gt;놓아주는 것과 버리는 것은 달라요&lt;/h2&gt;
&lt;p&gt;그래서 목업을 지우지 않았습니다. 지금도 저장소에 그대로 있어요.
다만 앱과 맞추는 일을 그만뒀습니다.&lt;/p&gt;
&lt;p&gt;지웠으면 “저건 실패한 시도”가 되는데, 그건 사실이 아니거든요.
목업은 2주 동안 제 몫을 다 했고, 그 결과가 지금 Kotlin 토큰으로 살아 있습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;목업은 낡은 게 아니라 제 일을 마쳤다.
다만 그걸 알아채는 데 며칠이 걸렸고, 그동안 두 벌을 맞추느라 시간을 좀 썼다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;되돌린 결정을 지우지 않는 이유가 이거예요.
“HTML 목업으로 시작하라”만 남겨두면 다음 사람은 그걸 끝까지 들고 갑니다.
언제 놓아야 하는지가 같이 적혀 있어야 완결된 조언이에요.&lt;/p&gt;
&lt;p&gt;그래서 제가 얻은 규칙은 이렇게 생겼습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;시작을 위한 도구는 시작이 끝나면 놓아준다.
그 도구가 나빠져서가 아니라, 붙잡고 있으면 그때부터 값을 치르기 때문에.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;이번-작업에서-정한-기준&quot;&gt;이번 작업에서 정한 기준&lt;/h2&gt;
&lt;p&gt;여기까지가 만드는 이야기예요.&lt;/p&gt;
&lt;p&gt;다음 두 편이 이 시리즈를 쓴 진짜 이유입니다.
코드가 아니라 절차를 통과하는 이야기거든요.
먼저 광고부터 붙여볼게요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/stock-calculator/first-app-admob.html&quot;&gt;3 — AdMob 붙이기 - 광고 세 종류, 동의(UMP), 그리고 계정 정지를 피하는 법&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>첫 앱 출시기 1 — 무엇을 만들었고, 무엇을 안 만들었나</title>
    <link href="https://blog.twinklelabs.kr/stock-calculator/first-app-what-i-built.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/stock-calculator/first-app-what-i-built.html</id>
    <published>2026-08-22T01:00:00.000Z</published>
    <updated>2026-08-22T01:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Stock Calculator"/>
    <category term="android"/>
    <category term="출시기"/>
    <category term="개인개발"/>
    <category term="product"/>
    <summary type="html">첫 커밋이 8월 9일, 테스트 트랙 게시가 8월 21일이었어요. 2주 동안 안드로이드 앱 하나를 만들어 Play 콘솔에 올리기까지를 네 편으로 나눠 씁니다. 첫 편은 무엇을 만들었나, 그리고 그보다 중요했던 무엇을 안 만들기로 했나입니다.</summary>
    <content type="html">&lt;p&gt;안드로이드 앱을 처음으로 스토어에 올려봤어요. 커밋 로그를 보면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;txt&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2026-08-09  Initial commit: add stock calculator app and design mockup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2026-08-21  Play 테스트 트랙 게시&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 커밋과 첫 업로드 사이가 12일이에요.
그 사이에 앱을 만들고, AdMob을 붙이고, 결제를 넣고, 서명 키를 만들고, 스토어 콘솔에 올렸습니다.
전부 처음 해보는 일이었어요.&lt;/p&gt;
&lt;p&gt;먼저 솔직하게 밝히고 시작할게요. 이 글을 쓰는 시점에 앱은 아직 테스트 트랙에 있습니다.
공개 출시는 안 했어요. 그러니 이건 “출시 성공기”가 아니라
“빈손에서 스토어 콘솔까지 가는 데 뭐가 필요했나”의 기록입니다.&lt;/p&gt;
&lt;p&gt;이 시리즈는 네 편이에요.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;무엇을 만들었고, 무엇을 안 만들었나 — 지금 이 글&lt;/li&gt;
&lt;li&gt;기획과 설계 — HTML 목업으로 빠르게 시작하고, 제때 놓아준 이야기&lt;/li&gt;
&lt;li&gt;AdMob 붙이기 — 광고 세 종류와 동의, 그리고 계정 정지를 피하는 법&lt;/li&gt;
&lt;li&gt;Play Store에 올리기 — 서명, 등록정보, 콘솔에 안 가는 법&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;첫 편인 이번 글에서는 무엇을 만들었는지, 그리고 그보다 훨씬 중요했던
무엇을 안 만들기로 했는지를 이야기할게요.&lt;/p&gt;
&lt;h1 id=&quot;시작은-제-불편이었어요&quot;&gt;시작은 제 불편이었어요&lt;/h1&gt;
&lt;p&gt;주식이 물렸습니다. 더 사면 평단가가 내려간다는 건 아는데
얼마까지 내려가는지는 모르겠더라고요.&lt;/p&gt;
&lt;p&gt;예를 들어 72,500원에 20주를 들고 있고 지금 가격이 58,000원이라고 해볼게요.
여기서 15주를 더 사면 평단가가 얼마가 될까요?
계산기 앱을 열어서 곱하기 두 번, 더하기 한 번, 나누기 한 번을 해야 나옵니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(72,500 × 20 + 58,000 × 15) ÷ 35 = 66,285.71…&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 계산하고 나면 진짜 궁금한 게 남아요.
“66,000원까지 내리려면 몇 주를 더 사야 하지?”&lt;/p&gt;
&lt;p&gt;이건 역산이라 계산기로는 못 풉니다. 손으로 식을 세워야 해요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;x = q₁(p₁ − t) / (t − p₂)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이걸 매번 종이에 쓰고 있는 저를 발견한 날, 앱을 만들기로 했습니다.&lt;/p&gt;
&lt;h1 id=&quot;만든-것--두-가지-질문에만-답합니다&quot;&gt;만든 것 — 두 가지 질문에만 답합니다&lt;/h1&gt;
&lt;p&gt;“물타기 계산기”라는 이름을 붙였고, 하는 일은 딱 둘이에요.&lt;/p&gt;
&lt;p&gt;물타기 — 이렇게 사면 평단가가 어디까지 내려가나
목표 평단가 — 거기까지 가려면 얼마나 더 사야 하나&lt;/p&gt;
&lt;p&gt;여기에 딸린 게 몇 개 있습니다. 계산 결과를 저장하고 종목별로 모아 보는 이력,
&lt;code&gt;500,000 ÷ 69,000&lt;/code&gt; 같은 식을 그대로 칠 수 있는 계산기 키패드,
그리고 라이트/다크 모드와 열한 개 언어.&lt;/p&gt;
&lt;p&gt;여기까지예요. 기능 목록이 이게 전부입니다.&lt;/p&gt;
&lt;h1 id=&quot;기획의-절반은-안-만들-것을-정하는-일이었어요&quot;&gt;기획의 절반은 안 만들 것을 정하는 일이었어요&lt;/h1&gt;
&lt;p&gt;만들다 보면 자꾸 붙이고 싶어져요. 실제로 세 번 흔들렸고, 세 번 다 안 하기로 했습니다.
나중에는 저장소 README에 아예 “하지 않는 것” 절을 따로 뒀어요.&lt;/p&gt;
&lt;h2 id=&quot;시세-연동을-하지-않습니다&quot;&gt;시세 연동을 하지 않습니다&lt;/h2&gt;
&lt;p&gt;“현재가를 자동으로 가져오면 편하지 않나?” 당연히 편하죠.
그런데 시세는 초 단위로 바뀌고, 무료로 안정적으로 받아올 방법이 마땅치 않습니다.&lt;/p&gt;
&lt;p&gt;손으로 넣게 하면 화면에 늘 낡은 숫자가 뜹니다.
그리고 낡은 시세로 계산한 수익률은 그냥 틀린 수익률이에요.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;틀린 손익은 없는 것만 못합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;게다가 한 번 이 선을 넘으면 “그럼 실시간은?” “그럼 알림은?”으로 끝없이 갑니다.
2주짜리 프로젝트가 아니게 돼요.&lt;/p&gt;
&lt;h2 id=&quot;보유-자산-관리를-하지-않습니다&quot;&gt;보유 자산 관리를 하지 않습니다&lt;/h2&gt;
&lt;p&gt;앱에 “종목”이라는 개념이 있어요. 삼성전자에 계산 두 건, 카카오에 한 건, 이런 식으로요.
그런데 이 종목은 증권사 계좌와 연결되지 않습니다. 보유 수량도 평단가도 들고 있지 않아요.&lt;/p&gt;
&lt;p&gt;이름과 아이콘과 목록에서의 자리, 그게 전부입니다.&lt;/p&gt;
&lt;p&gt;이유는 하나예요. 계산은 가정이니까요.
“이렇게 사면 어떻게 되나”를 굴려본 것이지 실제로 산 게 아닙니다.
종목이 “내 자산”을 뜻하게 두는 순간, 저장된 기록이 “내 계좌 현황”처럼 읽히고
사용자는 앱이 모르는 것을 안다고 믿게 돼요.&lt;/p&gt;
&lt;p&gt;그래서 이력 화면 발치에 이 한 줄이 늘 적혀 있습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;실제 매매가 아닌 계산 기록입니다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;서버를-두지-않습니다&quot;&gt;서버를 두지 않습니다&lt;/h2&gt;
&lt;p&gt;계정도 로그인도 동기화도 없어요. 기록은 기기 안에만 있습니다.&lt;/p&gt;
&lt;p&gt;편의를 포기한 것이지만 얻은 게 있어요.
개인정보 처리방침에 “계산은 이 기기를 떠나지 않습니다”를 그냥 사실로 적을 수 있습니다.
지킬 수 없는 약속을 적는 것보다 이쪽이 훨씬 낫고,
무엇보다 개인 개발자가 남의 금융 데이터를 서버에 들고 있는 건 감당할 일이 아니더라고요.&lt;/p&gt;
&lt;h1 id=&quot;2주-동안-무슨-일이-있었나&quot;&gt;2주 동안 무슨 일이 있었나&lt;/h1&gt;
&lt;p&gt;커밋 로그를 그대로 옮기면 이렇습니다.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;날짜&lt;/th&gt;&lt;th&gt;한 일&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;8/9&lt;/td&gt;&lt;td&gt;첫 커밋 — HTML 목업과 앱 뼈대, 디자인 시스템 이식&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8/18&lt;/td&gt;&lt;td&gt;광고·개인정보 처리방침·브랜드 아이콘·영어·스플래시&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8/20&lt;/td&gt;&lt;td&gt;계산 로직 분리, Room 마이그레이션, 키패드 개선&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8/21&lt;/td&gt;&lt;td&gt;UMP 동의 · 실제 AdMob 3종 · 업로드 키 서명 · 광고 제거 결제&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8/21&lt;/td&gt;&lt;td&gt;Play 비공개 테스트 트랙 게시&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8/22&lt;/td&gt;&lt;td&gt;아이콘 16종, 디자인 정리, 통화 아홉 · 언어 열하나&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;앞의 열흘은 만드는 시간이었고, 마지막 이틀이 올리는 시간이었어요.&lt;/p&gt;
&lt;p&gt;그런데 체감상 그 이틀이 앞의 열흘만큼 길었습니다.
서명 키, 광고 단위, 결제 상품, 등록정보, 스크린샷, 정책 설문.
코드를 짜는 일이 아니라 절차를 통과하는 일이라 검색으로도 잘 안 나오더라고요.&lt;/p&gt;
&lt;p&gt;그 이틀 이야기가 이 시리즈의 3·4편입니다.&lt;/p&gt;
&lt;h2 id=&quot;작업을-끝내고-보니&quot;&gt;작업을 끝내고 보니&lt;/h2&gt;
&lt;p&gt;다음 글은 만드는 쪽 이야기예요.
Figma 대신 HTML 목업 한 장으로 시작한 이유와, 혼자 만들 때 합의할 상대가 없다는 문제를
어떻게 풀었는지를 다룹니다. 그리고 그 목업을 왜 나중에 놓아줬는지도요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/stock-calculator/first-app-design-cycle.html&quot;&gt;2 — 기획과 설계 - Figma 대신 HTML 목업으로 시작했고, 나중에 놓아줬어요&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>조용히 실패하는 것들 1 — 떠난 제품은 두 저장소에 같은 문장을 남깁니다</title>
    <link href="https://blog.twinklelabs.kr/silent-failures/two-repos-one-sentence.html" rel="alternate"/>
    <id>https://blog.twinklelabs.kr/silent-failures/two-repos-one-sentence.html</id>
    <published>2026-08-21T12:00:00.000Z</published>
    <updated>2026-08-21T12:00:00.000Z</updated>
    <author><name>Heejeong Kim</name></author>
    <category term="Silent Failures"/>
    <category term="git"/>
    <category term="자동화"/>
    <category term="기록"/>
    <summary type="html">여러 저장소의 커밋을 한 일지로 모으는 도구를 만들었어요. 해시로 겹침을 걸렀는데 같은 일이 두 번 적혔습니다. 옮겨 심은 커밋은 같은 내용이어도 다른 지문을 갖거든요.</summary>
    <content type="html">&lt;p&gt;도구가 “됐다”고 말하고는 아무 일도 하지 않는 경우가 있어요.
에러도 안 나고, 종료 코드도 0이고, 로그도 깨끗한데 결과만 없는 경우요.&lt;/p&gt;
&lt;p&gt;그런 일을 며칠 사이에 세 번 겪었고, 세 편으로 나눠 적어보려고 합니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;떠난 제품은 두 저장소에 같은 문장을 남긴다 — 지금 이 글&lt;/li&gt;
&lt;li&gt;원자적인 편집은 통째로 실패하고, 무엇이 빠졌는지 말하지 않는다&lt;/li&gt;
&lt;li&gt;해시를 파일에 적는 순간 그 파일은 이력의 일부가 된다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;셋 다 git과 자동화 이야기예요. 그리고 셋 다 결론이 같습니다.
“돌렸다”는 확인이 아니라는 것.&lt;/p&gt;
&lt;p&gt;첫 편은 겹침 판정이 틀렸던 이야기입니다.&lt;/p&gt;
&lt;h1 id=&quot;여러-저장소를-한-일지로-모으고-있었어요&quot;&gt;여러 저장소를 한 일지로 모으고 있었어요&lt;/h1&gt;
&lt;p&gt;여러 제품을 하나의 기록 금고에 남깁니다.
날짜별 일지에 그날의 커밋 목록이 들어가고, 그건 도구가 채워요.&lt;/p&gt;
&lt;p&gt;도구는 저장소를 여러 개 훑습니다.
루트 하나, 그리고 제 저장소로 떠난 제품들이요.
훑어서 모으고, 겹치는 것을 거릅니다.&lt;/p&gt;
&lt;p&gt;겹침을 해시로 걸렀어요. 그리고 같은 일이 두 번 적혔습니다.&lt;/p&gt;
&lt;h1 id=&quot;제품이-떠나도-문장은-남습니다&quot;&gt;제품이 떠나도 문장은 남습니다&lt;/h1&gt;
&lt;p&gt;제품이 처음에는 루트 저장소 안에 살아요. 커지면 제 저장소로 떠나고요.
&lt;code&gt;git subtree&lt;/code&gt;로 떼어 내든 그냥 복사하든 결과는 같습니다.&lt;/p&gt;
&lt;p&gt;그 커밋 문장은 루트 이력에도 그대로 남아요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;루트 저장소     …  &quot;물타기 계산기 키패드 서랍을 다시 짠다&quot;  ← 3f2a1b9&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;제품 저장소     …  &quot;물타기 계산기 키패드 서랍을 다시 짠다&quot;  ← c84d0e7&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사람이 보면 하나의 일이죠. 그런데 도구가 보면 서로 다른 커밋 둘입니다.&lt;/p&gt;
&lt;h1 id=&quot;해시는-내용의-지문이-아니에요&quot;&gt;해시는 “내용”의 지문이 아니에요&lt;/h1&gt;
&lt;p&gt;해시로 걸러도 안 걸리는 이유는 단순합니다.&lt;/p&gt;
&lt;p&gt;커밋 해시는 이런 것들의 지문이에요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;트리(내용)&lt;/li&gt;
&lt;li&gt;부모 커밋&lt;/li&gt;
&lt;li&gt;시각&lt;/li&gt;
&lt;li&gt;작성자·커미터&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;옮겨 심은 커밋은 부모가 달라집니다.
그러니 트리가 완전히 같아도 지문이 달라요. &lt;code&gt;cherry-pick&lt;/code&gt;이나 &lt;code&gt;rebase&lt;/code&gt;도 마찬가지입니다.&lt;/p&gt;
&lt;p&gt;“같은 커밋인가”를 해시로 묻는 것은 같은 저장소 안에서만 통하는 질문이었어요.&lt;/p&gt;
&lt;h1 id=&quot;사람이-같은-일로-알아보는-근거&quot;&gt;사람이 “같은 일”로 알아보는 근거&lt;/h1&gt;
&lt;p&gt;그럼 사람은 뭘 보고 같은 일이라고 알까요?&lt;/p&gt;
&lt;p&gt;해시가 아니에요. 그날 그 문장입니다.&lt;/p&gt;
&lt;p&gt;그래서 도구의 열쇠를 바꿨어요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;날짜 + 제목&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이걸 열쇠로 겹침을 거릅니다.
저장소가 달라도 같은 날 같은 문장이면 하나로 봐요.&lt;/p&gt;
&lt;h1 id=&quot;그래서-커밋-문장이-도구의-입력이-됐어요&quot;&gt;그래서 커밋 문장이 도구의 입력이 됐어요&lt;/h1&gt;
&lt;p&gt;이 열쇠에는 조건이 붙습니다.
커밋 문장이 “무엇을 했는가”로 또렷해야 해요.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes catppuccin-latte tokyo-night&quot; style=&quot;--shiki-light:#4c4f69;--shiki-dark:#a9b1d6;--shiki-light-bg:#eff1f5;--shiki-dark-bg:#1a1b26; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;✗  fix&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;✗  update&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;✗  wip&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;○  키패드 서랍이 나가는 곡선으로 떠나게 한다&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;○  aapt 가 접는 줄바꿈을 검사가 잡게 한다&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fix&lt;/code&gt; 한 단어짜리 문장은 서로 다른 일까지 하나로 묶어버립니다.
같은 날 &lt;code&gt;fix&lt;/code&gt;를 두 번 쓰면 도구는 그중 하나를 지워요.&lt;/p&gt;
&lt;p&gt;커밋 메시지를 잘 쓰라는 말은 보통 “나중에 읽을 사람을 위해”인데,
여기서는 이유가 하나 더 생겼습니다. 도구가 읽거든요.&lt;/p&gt;
&lt;h1 id=&quot;도구는-지우지-않고-합칩니다&quot;&gt;도구는 지우지 않고 합칩니다&lt;/h1&gt;
&lt;p&gt;이 도구에 대해 나중에 하나 더 배웠어요.&lt;/p&gt;
&lt;p&gt;이력이 다시 쓰이면(스쿼시·리베이스) 옛 커밋은 어느 저장소에도 없게 됩니다.
그때 도구가 “없으니까” 하고 이미 적힌 줄을 걷어내면,
그날 무엇을 했는지가 세상에서 사라져요.&lt;/p&gt;
&lt;p&gt;실제로 한 번 그랬습니다.
루트를 두 커밋으로 다시 쓰자 옛 시절 156줄이 일지에서 지워졌어요.&lt;/p&gt;
&lt;p&gt;그래서 지금 도구는 표식 안의 옛 줄과 새로 찾은 줄을 합집합으로 씁니다.
새로 못 찾았다는 것이 “그 일이 없었다”는 뜻은 아니니까요.&lt;/p&gt;
&lt;h2 id=&quot;다시-같은-일을-한다면&quot;&gt;다시 같은 일을 한다면&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;저장소를 넘나드는 “같은 일” 판정에 해시를 쓰지 않습니다. 부모가 다르면 지문이 달라요.&lt;/li&gt;
&lt;li&gt;열쇠는 사람이 같은 일로 알아보는 것 — 날짜와 문장입니다.&lt;/li&gt;
&lt;li&gt;그 열쇠가 잘 들으려면 커밋 문장이 또렷해야 해요. &lt;code&gt;fix&lt;/code&gt;는 열쇠가 되지 못합니다.&lt;/li&gt;
&lt;li&gt;기록을 다루는 도구는 지우는 쪽이 아니라 합치는 쪽으로 틀립니다.
잘못 지운 기록은 되찾을 데가 없거든요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-작업을-위한-메모&quot;&gt;다음 작업을 위한 메모&lt;/h2&gt;
&lt;p&gt;이번 건 도구가 잘못 판정한 경우였어요. 그래도 결과물은 남았죠.&lt;/p&gt;
&lt;p&gt;다음 글은 조금 더 고약합니다.
도구가 아무것도 안 올려놓고 그 사실을 말해주지 않은 이야기예요.&lt;/p&gt;
&lt;p&gt;👉 &lt;a href=&quot;/silent-failures/atomic-edit-says-nothing.html&quot;&gt;2 — 원자적인 편집은 통째로 실패하고, 무엇이 빠졌는지 말하지 않아요&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
</feed>
