<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Next Intl on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/ko/tags/next-intl/</link><description>Recent content in Next Intl on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Thu, 07 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/ko/tags/next-intl/index.xml" rel="self" type="application/rss+xml"/><item><title>popcon 개발일지 #11 — 크레딧 시스템, R2 마이그레이션, ToonOut, 그리고 Brutal 리디자인</title><link>https://ice-ice-bear.github.io/ko/posts/2026-05-07-popcon-dev11/</link><pubDate>Thu, 07 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-05-07-popcon-dev11/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post popcon 개발일지 #11 — 크레딧 시스템, R2 마이그레이션, ToonOut, 그리고 Brutal 리디자인" /&gt;&lt;h2 id="개요"&gt;개요
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-popcon-dev10/" &gt;이전 글: #10 — 베타 모집, 풍선 인디케이터, 카운트다운&lt;/a&gt;을 쓴 뒤로 보름 동안 popcon에는 dev10 한 편으로 묶기 어려운 변화가 들어갔다. 매팅 모델 교체, 결제 인프라(크레딧), Cloudflare R2 스토리지 컷오버, brutal 리디자인, 한국어 i18n까지 — 156개 커밋이 사실상 다섯 개의 독립 마일스톤이다.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Start["popcon dev #10 (594cceb)"] --&gt; M1["매팅 모델 스왑 &amp;lt;br/&amp;gt; ToonOut on gray bg"]
 Start --&gt; M2["크레딧 시스템 &amp;lt;br/&amp;gt; Credits/CreditCode/CreditLedger"]
 Start --&gt; M3["D1 brutal 리디자인 &amp;lt;br/&amp;gt; 토큰/폰트/프리미티브 재작성"]
 Start --&gt; M4["Cloudflare R2 컷오버 &amp;lt;br/&amp;gt; dual-write → backfill → drop"]
 Start --&gt; M5["한국어 i18n &amp;lt;br/&amp;gt; next-intl + locale prefix"]
 M1 --&gt; End["popcon dev #11 (411c5ec)"]
 M2 --&gt; End
 M3 --&gt; End
 M4 --&gt; End
 M5 --&gt; End&lt;/pre&gt;&lt;p&gt;이번 글은 다섯 개를 한 번에 다루지만, 각 흐름을 따라가면 결국 같은 질문이 반복된다 — &lt;strong&gt;&amp;ldquo;기존 시스템을 멈추지 않고 어떻게 새 레일로 갈아탈까.&amp;rdquo;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="매팅-모델-birefnet--toonout"&gt;매팅 모델: BiRefNet → ToonOut
&lt;/h2&gt;&lt;p&gt;popcon은 캐릭터 이미지에서 배경을 분리해 12개 이모티콘 액션으로 합성한다. 기존 매팅 모델은 일반 사진 기준이라 애니 캐릭터의 머리카락/투명 영역에서 자주 무너졌다.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://github.com/MatteoKartoon/BiRefNet" target="_blank" rel="noopener"
 &gt;ToonOut&lt;/a&gt;은 &lt;a class="link" href="https://github.com/zhengpeng7/birefnet" target="_blank" rel="noopener"
 &gt;BiRefNet&lt;/a&gt;을 1,228장의 애니 이미지로 fine-tuning한 모델이다. 픽셀 정확도가 95.3% → 99.5%로 올라간다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# gpu_worker — ToonOut에 입력하기 전 회색 배경에 합성&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# (ToonOut training-time gray = #808080)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_swap_bg_to_gray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;Soft white-key compositor: alpha-blend onto #808080.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;전후 처리에서 두 가지 디테일이 잡혔다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;회색 배경 단일 소스&lt;/strong&gt; — &lt;code&gt;bg_color&lt;/code&gt;를 백엔드 단일 진리원(single source of truth)으로 만들고 &lt;code&gt;#808080&lt;/code&gt;으로 통일 (commit &lt;code&gt;430f985&lt;/code&gt;). 이전에는 프런트엔드와 워커가 각자 다른 톤의 회색을 쓰고 있었다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pylette로 캐릭터별 회색 픽&lt;/strong&gt; — Rec.709 luminance 규칙으로 캐릭터의 평균 밝기에 맞는 회색을 골라준다 (commit &lt;code&gt;94544df&lt;/code&gt;). &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-pylette/" &gt;Pylette 글&lt;/a&gt;에서 다뤘던 라이브러리를 실제로 import해서 쓰게 됐다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;리팩터링 중 동적 indirection을 cargo-cult로 판정해서 걷어냈고, mask-fill threshold도 이름을 줬다 (&lt;code&gt;081ddd6&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="크레딧-시스템-결제-인프라-한-사이클"&gt;크레딧 시스템: 결제 인프라 한 사이클
&lt;/h2&gt;&lt;p&gt;베타 끝나고 유료화 전환을 위해 크레딧 시스템을 처음부터 깔았다. SQLAlchemy ORM부터 프런트엔드 402 핸들러까지 5일 안에 한 바퀴를 돌렸다.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Code["관리자 CLI mint &amp;lt;br/&amp;gt; CreditCode (POPxxxxx)"] --&gt; Redeem["redeem 모달 &amp;lt;br/&amp;gt; 코드 → 잔액"]
 Redeem --&gt; Ledger["CreditLedger &amp;lt;br/&amp;gt; charge / refund / grant"]
 Action["editor 액션 &amp;lt;br/&amp;gt; (generate/refine/animate)"] --&gt; Quote["pre-flight quote &amp;lt;br/&amp;gt; 잔액 부족 시 gate"]
 Quote --&gt; Ledger
 Ledger -- "402 emit" --&gt; Pill["header 잔액 pill &amp;lt;br/&amp;gt; 글로벌 redeem 모달"]
 Ledger --&gt; Account["/account 페이지 &amp;lt;br/&amp;gt; 잔액/redeem/이력"]&lt;/pre&gt;&lt;p&gt;핵심 결정 셋:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ledger 패턴&lt;/strong&gt; — &lt;code&gt;CreditLedger&lt;/code&gt;에 모든 변동을 append-only로 기록하고, &lt;code&gt;Credits&lt;/code&gt; 테이블의 &lt;code&gt;balance&lt;/code&gt; 컬럼은 캐시. 모든 charge/refund는 strict transaction (&lt;code&gt;e28b100&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;402 글로벌 이벤트&lt;/strong&gt; — 백엔드가 잔액 부족 시 HTTP 402를 던지면, 프런트엔드 &lt;code&gt;useCredits()&lt;/code&gt; 훅이 자동으로 잔액을 새로고침하고 글로벌 redeem 모달을 띄운다 (&lt;code&gt;d25739e&lt;/code&gt;, &lt;code&gt;1a32900&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;실패 단계 환불&lt;/strong&gt; — 이모티콘 생성 중간에 에러가 나면 그 단계의 크레딧을 자동 환불 (&lt;code&gt;6d7cc7f&lt;/code&gt;). 사용자가 환불을 직접 요청하지 않게.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;중간에 작은 사고가 있었다. Gemini의 &lt;code&gt;image_size&lt;/code&gt; 파라미터를 가격 체계와 맞추려고 &lt;code&gt;&amp;quot;0.5K&amp;quot;&lt;/code&gt;로 보냈는데, 이건 Gemini가 거부하는 값이다 (&lt;code&gt;b1ac23f&lt;/code&gt; revert → &lt;code&gt;55eda01&lt;/code&gt;에서 &lt;code&gt;&amp;quot;512&amp;quot;&lt;/code&gt;로 정정). API 요금 계산용 표기와 API 입력 표기가 다른 케이스 — 둘이 같다고 가정한 게 문제였다.&lt;/p&gt;
&lt;p&gt;또 &lt;code&gt;360115e&lt;/code&gt; 커밋이 흥미롭다. 코드 리팩터링 중에 브랜드 prefix &lt;code&gt;POP&lt;/code&gt;을 &lt;code&gt;P0P&lt;/code&gt; (영문 O를 0으로)으로 잘못 바꿨던 걸 되돌렸다. AI가 &amp;ldquo;스타일 통일&amp;quot;을 너무 적극적으로 한 사례.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="d1-brutal-리디자인-토큰부터-페이지까지"&gt;D1 brutal 리디자인: 토큰부터 페이지까지
&lt;/h2&gt;&lt;p&gt;기존 popcon은 generic Tailwind 룩이었다. 플라이어/브랜딩과 맞추기 위해 brutal 스타일로 전면 교체했다 — 두꺼운 검정 보더, 강한 그림자, 5색 톤 시스템, 굵은 산세리프.&lt;/p&gt;
&lt;p&gt;새 폰트 스택:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Archivo Black&lt;/strong&gt; — 영문 헤드라인&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black Han Sans&lt;/strong&gt; — 한글 헤드라인&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jua&lt;/strong&gt; — 한글 본문&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JetBrains Mono&lt;/strong&gt; — 코드/숫자&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pretendard&lt;/strong&gt; — 한글 폴백&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;/* tokens.css — 5톤 brutal 팔레트 */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nd"&gt;root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fafaf7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* 본문 배경 */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* 본문 텍스트 + border */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--violet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#7c3aed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* 브랜드 (P logo, 액션) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--yellow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fbbf24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* 활성 강조 (ZIP 버튼 등) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--pink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ec4899&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* erase / 경고 */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--mint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#10b981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* success */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;프리미티브를 새로 짰다 — &lt;code&gt;Card&lt;/code&gt;, &lt;code&gt;Chip&lt;/code&gt; (5톤×2사이즈), &lt;code&gt;StatusDot&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;Textarea&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt; (5 variants × 3 sizes), &lt;code&gt;StepIndicator&lt;/code&gt;. 모두 brutal 스타일로 다시 작성 (&lt;code&gt;769df10&lt;/code&gt; ~ &lt;code&gt;0e013a8&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;페이지를 한 장씩 갈아끼우는 방식으로 진행했다 — landing → editor 패널들 → archive → account → auth 모달 → header. 각 commit이 한 페이지/패널이라 리뷰가 쉬웠다.&lt;/p&gt;
&lt;p&gt;가장 까다로웠던 건 &lt;strong&gt;scrim/모달 배경&lt;/strong&gt; 처리였다. 기존엔 흰색 베일을 깔았는데 brutal 스타일에선 ink scrim(검정 반투명)이 맞았다. 그런데 SAM2/matte refine 모달에선 ink scrim이 너무 강해서 레퍼런스 이미지가 안 보임 → 모달별로 scrim을 분기 (&lt;code&gt;99b1908&lt;/code&gt;, &lt;code&gt;4096ba7&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;WCAG AA 점검도 한 번 돌렸다. 핑크 배경 위 흰색 텍스트가 contrast 미달이어서 ink로 교체 (&lt;code&gt;4827ed4&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="cloudflare-r2-컷오버-4단계-phase-분리"&gt;Cloudflare R2 컷오버: 4단계 phase 분리
&lt;/h2&gt;&lt;p&gt;popcon은 생성된 이모티콘 zip/APNG/video를 fly.io 머신의 로컬 디스크에 쓰고 있었다. 머신이 늘면 자산이 분산돼서 다운로드 라우팅이 깨진다. R2(Cloudflare의 S3 호환 객체 스토리지)로 옮기기로 결정.&lt;/p&gt;
&lt;p&gt;다운타임 없이 옮기려고 4 phase로 쪼갰다:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Phase&lt;/th&gt;
 &lt;th&gt;내용&lt;/th&gt;
 &lt;th&gt;PR&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;R2 클라이언트 + &lt;code&gt;blob_key&lt;/code&gt; DB 컬럼 추가&lt;/td&gt;
 &lt;td&gt;#5&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Worker dual-write — 로컬 디스크 + R2 둘 다 기록&lt;/td&gt;
 &lt;td&gt;#6&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;백필 스크립트 + 프런트엔드가 R2 URL을 absolute로 패스스루&lt;/td&gt;
 &lt;td&gt;#7&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;D&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;레거시 파일 라우트 제거 + &lt;code&gt;/download_job&lt;/code&gt; 302 리다이렉트 + scratch GC&lt;/td&gt;
 &lt;td&gt;#8&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;각 phase 사이에 트래픽이 정상인지 확인하고 다음으로 넘어갔다. dual-write 단계에선 디스크와 R2 둘 다 쓰니까 비용은 잠깐 늘었지만, 컷오버 안전성을 샀다.&lt;/p&gt;
&lt;p&gt;후속 정리 두 개:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rehydrate URLs from R2 keys&lt;/strong&gt; (&lt;code&gt;b43e802&lt;/code&gt;) — DB에 R2 URL을 그대로 박지 않고 &lt;code&gt;blob_key&lt;/code&gt;에서 매번 derive. R2 endpoint가 바뀌어도 마이그레이션 없이 따라간다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;레거시 자산 라우트 복구&lt;/strong&gt; (&lt;code&gt;1e08937&lt;/code&gt;) — 이전에 시작한 작업물을 가진 사용자를 위해 file 라우트 일부를 다시 살림. R2 URL을 filesystem path 컬럼에 잘못 미러링한 버그도 같이 잡았다 (&lt;code&gt;83d62c4&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="한국어-i18n-next-intl--locale-prefixed-routes"&gt;한국어 i18n: next-intl + locale-prefixed routes
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 URL1["/editor"] --&gt; Proxy["proxy.ts &amp;lt;br/&amp;gt; Next 16-style"]
 URL2["/ko/editor"] --&gt; Proxy
 URL3["/en/editor"] --&gt; Proxy
 Proxy --&gt; Locale{"locale 추출"}
 Locale --&gt; Layout["[locale]/layout.tsx &amp;lt;br/&amp;gt; getMessages()"]
 Layout --&gt; Page["페이지 렌더 &amp;lt;br/&amp;gt; useTranslations()"]&lt;/pre&gt;&lt;p&gt;next-intl + locale-prefixed routes로 한국어를 추가했다. 핵심 결정 두 개:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;page를 &lt;code&gt;[locale]&lt;/code&gt; 세그먼트 아래로 이동&lt;/strong&gt; — &lt;code&gt;app/page.tsx&lt;/code&gt; → &lt;code&gt;app/[locale]/page.tsx&lt;/code&gt;. layout도 root layout과 locale layout으로 분리 (&lt;code&gt;fe1eaa3&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Next 16 proxy.ts로 locale 라우팅&lt;/strong&gt; — middleware 대신 proxy 패턴 (&lt;code&gt;4f322e2&lt;/code&gt;). 정적 라우팅이 가능해서 캐시가 잘 먹는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;번역은 namespace별로 dictionary 파일을 쪼갰다 — &lt;code&gt;home&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;, &lt;code&gt;account&lt;/code&gt;, &lt;code&gt;redeem&lt;/code&gt;, &lt;code&gt;actions&lt;/code&gt;, &lt;code&gt;picker&lt;/code&gt;, &amp;hellip; 각 페이지/패널별 commit이 하나씩 있어서 grep 가능하다.&lt;/p&gt;
&lt;p&gt;언어 스위처에서 한 가지 버그가 잡혔다. 언어 전환 시 search params가 사라져서 editor에서 진행 중인 job이 끊기는 문제 — &lt;code&gt;Link&lt;/code&gt;/&lt;code&gt;router&lt;/code&gt; 모두 locale-aware 래퍼로 교체해서 search params 보존 (&lt;code&gt;d644b1b&lt;/code&gt;, PR #12).&lt;/p&gt;
&lt;p&gt;또 in-app browser(KakaoTalk, Instagram 등)에서 Google 로그인이 차단되는 문제도 발견했다. &lt;code&gt;iab=1&lt;/code&gt; 같은 쿼리로 외부 브라우저로 이스케이프하는 가드를 추가 (&lt;code&gt;29cd743&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="운영-skip_runpod-가드와-sync-pod-id-스크립트"&gt;운영: SKIP_RUNPOD 가드와 sync-pod-id 스크립트
&lt;/h2&gt;&lt;p&gt;배포는 fly.io(API/프런트엔드) + RunPod(GPU 워커) + GitHub Actions cron 스케줄러 조합이다. 새벽 시간대 RunPod 비용을 줄이려고 스케줄러로 pod를 끄고 켜는데, 수동으로 pod를 띄워두면 스케줄러가 같이 끄는 사고가 났다.&lt;/p&gt;
&lt;p&gt;해결: &lt;code&gt;SKIP_RUNPOD&lt;/code&gt; 환경 변수 가드 (&lt;code&gt;e3fa9fa&lt;/code&gt;). 이 플래그가 켜져 있으면 스케줄러가 pod를 건드리지 않는다. 수동 운영용 escape hatch.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sync-pod-id&lt;/code&gt; 스크립트도 추가 (&lt;code&gt;783238b&lt;/code&gt;) — 새 RunPod ID를 fly secret에 자동으로 동기화한다. 이전엔 수동으로 fly.io 환경변수를 업데이트해서 까먹기 쉬웠다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fly(frontend)&lt;/code&gt; 한 줄도 의외로 중요했다 (&lt;code&gt;edf3d18&lt;/code&gt;, PR #9). frontend 머신을 1대 warm으로 유지하고 메모리 512MB로 고정. 콜드 스타트 1.5초 → 200ms.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;p&gt;156개 커밋을 한 글에 묶고 보니 &lt;strong&gt;흐름이 직렬이 아니라 병렬&lt;/strong&gt;이었다. 매팅 모델 교체와 R2 마이그레이션은 백엔드/워커 쪽, brutal 리디자인은 프런트엔드, 크레딧과 i18n은 풀스택. 같은 시간대에 다섯 트랙이 동시에 굴러갔는데 서로 머지 충돌이 거의 없었던 건 모듈 경계가 또렷해서다.&lt;/p&gt;
&lt;p&gt;특히 R2 컷오버를 4 phase로 쪼갠 게 회고감이 좋다. dual-write phase에서 비용 잠깐 더 쓰는 대신 롤백 가능성을 샀다 — 만약 phase B에서 문제가 생겼어도 디스크가 truth로 남아 있어서 R2 코드만 끄면 됐다.&lt;/p&gt;
&lt;p&gt;크레딧 시스템 ledger 패턴은 다시 써도 같은 선택을 할 것 같다. &lt;code&gt;Credits.balance&lt;/code&gt;를 캐시로 두고 &lt;code&gt;CreditLedger&lt;/code&gt;를 append-only로 기록하면, 잔액에 의심이 갈 때 ledger를 재연산해서 검증할 수 있다. Stripe도 이 패턴이다.&lt;/p&gt;
&lt;p&gt;리디자인은 토큰/프리미티브를 먼저 새로 짠 뒤에 페이지를 한 장씩 갈아끼운 게 결정적이었다. 페이지를 먼저 손대면 새 토큰이 안 박히는 옛 컴포넌트가 계속 남는다.&lt;/p&gt;
&lt;p&gt;다음 dev #12에서 다룰 것: 결제 게이트(KG이니시스/PortOne) 연동, ToonOut 매팅 품질 A/B(전 모델 vs ToonOut), 한국어 i18n에서 빠진 미세 영역(에러 토스트, 관리자 CLI 메시지).&lt;/p&gt;</description></item></channel></rss>