<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Next Js on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/ko/tags/next-js/</link><description>Recent content in Next Js 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-js/index.xml" rel="self" type="application/rss+xml"/><item><title>hybrid-image-search 개발일지 #18 — OpenAI gpt-image-2 합류, 모델/제품 라이브러리, 그리고 내부 권한 분리</title><link>https://ice-ice-bear.github.io/ko/posts/2026-05-07-hybrid-search-dev18/</link><pubDate>Thu, 07 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-05-07-hybrid-search-dev18/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search 개발일지 #18 — OpenAI gpt-image-2 합류, 모델/제품 라이브러리, 그리고 내부 권한 분리" /&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-hybrid-search-dev17/" &gt;이전 글: #17 — 톤 풀 swap, 모델 인젝션 prompt v2&lt;/a&gt;을 쓴 이래 73개 커밋이 들어갔다. 가장 큰 변화는 &lt;strong&gt;인젝션 모드 자체를 버린 것&lt;/strong&gt; — 한 화면에 여러 톤 모드를 펼쳐놓던 UX를 모델/제품 탭 두 개로 단순화했다. 동시에 비교용 사이드 B를 OpenAI gpt-image-2로 라우팅하기 시작했다.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 Old["dev #17 까지 &amp;lt;br/&amp;gt; injection-mode pills (5톤)"] --&gt; Refactor["pills 제거 &amp;lt;br/&amp;gt; model/product 탭"]
 Refactor --&gt; A["Side A: Gemini 3.1 Flash &amp;lt;br/&amp;gt; (메인)"]
 Refactor --&gt; B["Side B: OpenAI gpt-image-2 &amp;lt;br/&amp;gt; (비교)"]
 Library["per-user library &amp;lt;br/&amp;gt; model + product"] --&gt; A
 Library --&gt; B
 Internal["internal 권한 게이트 &amp;lt;br/&amp;gt; tone-lock + S3 admin"] --&gt; A&lt;/pre&gt;&lt;p&gt;73개 커밋을 다섯 흐름으로 묶었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="openai-gpt-image-2를-사이드-b로-합류"&gt;OpenAI gpt-image-2를 사이드 B로 합류
&lt;/h2&gt;&lt;p&gt;지금까지 hybrid는 Gemini 단일 백엔드였다. dev #18에서는 비교 평가를 위해 사이드 B를 OpenAI &lt;code&gt;gpt-image-2&lt;/code&gt;로 라우팅하기 시작했다.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 UI["프런트엔드 generate"] --&gt; Backend["FastAPI /generate"]
 Backend --&gt; Gather["asyncio.gather()"]
 Gather --&gt; SideA["Side A &amp;lt;br/&amp;gt; Gemini 3.1 Flash"]
 Gather --&gt; SideB["Side B &amp;lt;br/&amp;gt; OpenAI gpt-image-2"]
 SideA --&gt; CompareUI["frontend a/b 키보드 비교"]
 SideB --&gt; CompareUI&lt;/pre&gt;&lt;p&gt;핵심 커밋:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AsyncOpenAI&lt;/code&gt; 클라이언트와 OpenAI image-gen config 와이어링&lt;/strong&gt; (&lt;code&gt;052d42f&lt;/code&gt;) — 환경 변수, 타임아웃, retry 정책을 backend config에 추가.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;공유 image IO helper와 OpenAI image service&lt;/strong&gt; (&lt;code&gt;1fb9b43&lt;/code&gt;) — Gemini와 OpenAI 응답을 공통 포맷으로 변환하는 어댑터.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5톤 → side A/B 의미론으로 리팩터링&lt;/strong&gt; (&lt;code&gt;d91067e&lt;/code&gt;, &lt;code&gt;ec38fa8&lt;/code&gt;) — &lt;code&gt;tone3&lt;/code&gt;, &lt;code&gt;tone5&lt;/code&gt; 같은 필드명을 &lt;code&gt;side_a&lt;/code&gt;, &lt;code&gt;side_b&lt;/code&gt;로 교체. 더 이상 톤 종류가 아니라 비교용 측면이라는 의미.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;gather에서 cancellation 차단 + unsupported quality 파라미터 제거&lt;/strong&gt; (&lt;code&gt;8759a78&lt;/code&gt;) — &lt;code&gt;asyncio.gather&lt;/code&gt;는 한쪽 task가 raise하면 다른 쪽이 cancel될 수 있다. 둘 다 살리려면 &lt;code&gt;return_exceptions=True&lt;/code&gt;로 shield하고 따로 처리.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;코너 케이스 두 개:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;aspect ratio 매핑&lt;/strong&gt; — gpt-image-2는 &lt;code&gt;1024x1024&lt;/code&gt;, &lt;code&gt;1024x1792&lt;/code&gt;, &lt;code&gt;1792x1024&lt;/code&gt;만 지원 (&lt;code&gt;97f7204&lt;/code&gt;). UI에서 입력한 임의 비율을 가장 가까운 지원 사이즈로 매핑.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B 실패 메시지 surface&lt;/strong&gt; (&lt;code&gt;7d31f62&lt;/code&gt;) — 비교용 사이드라도 실패하면 UI에 알려줘야 한다. 조용히 빠지면 비교 결과가 한쪽만 나와서 혼란.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="인젝션-모드-폐기-모델제품-라이브러리-도입"&gt;인젝션 모드 폐기, 모델/제품 라이브러리 도입
&lt;/h2&gt;&lt;p&gt;dev #17까지는 &amp;ldquo;tone injection mode&amp;quot;라는 추상화가 있었다. 5톤 × 사용자 업로드 모델 × 옵션 매트릭스가 화면에 펼쳐져서 학습 비용이 높았다. dev #18에서 정면으로 갈아치웠다 — &lt;strong&gt;모델 탭과 제품 탭 두 개&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Lib["LibraryTab"] --&gt; ModelTab["모델 (인물 사진)"]
 Lib --&gt; ProductTab["제품 (오브젝트 사진)"]
 ModelTab --&gt; ModelUpload["직접 업로드"]
 ModelTab --&gt; ModelGen["Gemini ID-photo로 재생성"]
 ProductTab --&gt; ProductUpload["업로드 + 자동 전처리"]
 ProductUpload --&gt; AutoPick["ready 시 자동 픽"]
 ModelGen --&gt; Generate["generate 호출"]
 ProductUpload --&gt; Generate&lt;/pre&gt;&lt;p&gt;흐름:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;사용자별 자산 라이브러리&lt;/strong&gt; (&lt;code&gt;b933191&lt;/code&gt;) — 업로드한 모델/제품을 사용자 계정에 저장. 다른 톤 만들 때 재사용 가능.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;인젝션 모드 pill을 모델/제품 탭으로 교체&lt;/strong&gt; (&lt;code&gt;1450767&lt;/code&gt;) — UI 단순화. &amp;ldquo;어떤 모드를 쓸지&amp;rdquo; 정하는 단계가 사라졌다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;모델 ID-photo 재생성&lt;/strong&gt; (&lt;code&gt;db64b05&lt;/code&gt;) — 업로드한 인물 사진을 Gemini로 ID 사진 스타일로 정제. 일관성 있는 모델 슬롯을 만들기 위함.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;role-aware prompt directives&lt;/strong&gt; (&lt;code&gt;ffb8ccf&lt;/code&gt;) — 모델/제품 레퍼런스가 프롬프트에 들어갈 때 역할이 명시된다. &amp;ldquo;이 사람을 모델로&amp;rdquo;, &amp;ldquo;이 오브젝트를 제품으로&amp;rdquo;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;제품 업로드 자동 전처리 + ready 시 자동 픽&lt;/strong&gt; (&lt;code&gt;69db8c2&lt;/code&gt;) — 업로드 → 백그라운드 전처리 → 끝나면 자동으로 활성화. 사용자가 한 단계 덜 누른다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;processing state surfacing + toast&lt;/strong&gt; (&lt;code&gt;f3ff587&lt;/code&gt;) — 전처리 중인 자산은 별도 상태로 표시. 어색한 silent wait 제거.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;중간에 한 번 후퇴가 있었다. &lt;strong&gt;모델 자동 인젝션을 켰다 껐다 다시 켰다&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bdf0aae&lt;/code&gt; — auto model injection 끄고 직접 업로드만 (label wrap 버그도 같이 픽스)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;394f91f&lt;/code&gt; — auto model injection 다시 복원, generated-image drop도 받게&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;직접 업로드 only일 때 사용자가 이미지를 한 장씩 올려야 해서 마찰이 컸다. 결국 자동 인젝션이 default가 되고, 직접 업로드는 옵션으로 남았다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="톤-풀-큐레이션-0428--0429--0504"&gt;톤 풀 큐레이션: 0428 → 0429 → 0504
&lt;/h2&gt;&lt;p&gt;생성 품질의 8할은 톤 레퍼런스 풀에서 나온다. 풀이 너무 다양하면 결과가 뒤죽박죽되고, 너무 좁으면 모든 결과가 비슷해진다.&lt;/p&gt;
&lt;p&gt;이번 사이클의 큐레이션 작업:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;0428 모델 셀렉션 풀로 model_image_ref 스왑&lt;/strong&gt; (&lt;code&gt;c1e5d39&lt;/code&gt;) — 0428 셋이 더 일관된 lighting을 보여줘서 메인 모델 풀 교체.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;two-category tones + person-aware model slot&lt;/strong&gt; (&lt;code&gt;cb3a260&lt;/code&gt;) — 톤을 2개 카테고리(natural/film, studio/clean)로 나누고, 인물이 있는 톤일 때만 모델 슬롯 활성화.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;0429 subfolder로 auto-pick 스코프 제한&lt;/strong&gt; (&lt;code&gt;27d335d&lt;/code&gt;) — 자동으로 톤을 고를 때 0429 큐레이션 셋만 후보로 둠. 노이즈 컷.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;slug-named tone refs를 generation_logs에 rewrite&lt;/strong&gt; (&lt;code&gt;76a1a64&lt;/code&gt;) — S3 corpus를 swap하면서 path naming이 바뀌어서 기존 로그를 다시 매핑.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a(natural,film) 톤 풀 0429 → 0504 reseed&lt;/strong&gt; (&lt;code&gt;c43214e&lt;/code&gt;, 마지막 커밋) — 가장 자주 쓰이는 톤 카테고리를 최신 셋으로 교체.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;scripts/&lt;/code&gt;에 S3 corpus swap 유틸리티들을 기록으로 남겼다 (&lt;code&gt;f169dd4&lt;/code&gt;). 다음 큐레이션 사이클에서 같은 작업을 반복할 때 쓸 수 있게.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;nginx&lt;/code&gt; 한 줄 픽스도 의외로 컸다 (&lt;code&gt;9f252ff&lt;/code&gt;). 백엔드 타임아웃과 nginx의 &lt;code&gt;/api/&lt;/code&gt; 타임아웃이 어긋나서, OpenAI 응답이 느릴 때 nginx가 먼저 502를 던지고 백엔드의 retry까지 trigger하는 이상한 상황이 있었다. 정렬 + upstream retry 비활성화로 해결.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="internal-vs-external-권한-분리"&gt;Internal vs External: 권한 분리
&lt;/h2&gt;&lt;p&gt;이 사이클에서 처음으로 &lt;strong&gt;internal 사용자&lt;/strong&gt;(팀 내부) 개념이 들어왔다. 데모 데이/외부 베타에서는 보여주면 안 되는 기능들이 있었기 때문.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 User["로그인 사용자"] --&gt; Check{"is_internal?"}
 Check -- "yes" --&gt; Internal["Internal 기능 노출"]
 Check -- "no" --&gt; External["External (default)"]
 Internal --&gt; ToneLock["tone refs pin &amp;lt;br/&amp;gt; 다음 generation에 잠금"]
 Internal --&gt; Admin["S3 image manager &amp;lt;br/&amp;gt; 톤/모델/제품 큐레이션"]
 External --&gt; Generate["일반 generate flow"]&lt;/pre&gt;&lt;p&gt;세 PR로 분리해서 머지:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PR #16 — internal-vs-external user tiers + UI gating&lt;/strong&gt; (&lt;code&gt;f33e9d0&lt;/code&gt;) — DB에 &lt;code&gt;is_internal&lt;/code&gt; 컬럼 추가, UI에서 internal-only 컴포넌트는 plain &lt;code&gt;&amp;lt;&amp;gt;&amp;lt;/&amp;gt;&lt;/code&gt;로 단락.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #17 — internal-only tone-lock&lt;/strong&gt; (&lt;code&gt;199a405&lt;/code&gt;) — 같은 톤 레퍼런스를 여러 generation에 고정해서 비교 evaluation을 깨끗하게.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #18 — internal-only S3 image manager&lt;/strong&gt; (&lt;code&gt;8096425&lt;/code&gt;) — 웹 UI에서 톤/모델/제품 corpus를 직접 관리. 이전엔 S3 콘솔로 직접 들어가야 했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;feat/admin-s3-manager&lt;/code&gt; 브랜치는 main을 두 번 머지해야 했다 (&lt;code&gt;9d5fa1e&lt;/code&gt;, &lt;code&gt;a35bf53&lt;/code&gt;). 다른 흐름들이 동시에 들어가서 conflict가 누적됐다 — 큰 흐름 머지 직후에 admin 브랜치를 sync해두는 게 좋다는 교훈.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="카메라렌즈-피커-ux-다듬기"&gt;카메라/렌즈 피커 UX 다듬기
&lt;/h2&gt;&lt;p&gt;카메라/렌즈 선택 UX를 한 사이클 동안 한 줄씩 다듬었다.&lt;/p&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;2439c98&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;angle picker dropdown에 thumbnail 표시 (선택 전 미리보기)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;4f615a7&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;레퍼런스 이미지 hover 시 zoom 버튼 노출&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;5be9daa&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&amp;ldquo;카메라 &amp;amp; 렌즈&amp;quot;로 이름 변경, 렌즈 random default, 모델 creator 추가&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;b4aeed3&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;None 옵션 명시 표시 + LensPicker에 None 선택지&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;228ff9f&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;LensPicker가 선택 후 자동 닫힘&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;024253e&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;angle/lens default가 none일 때도 picker 클릭 가능&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;bb13dd3&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;하단 바 정리 — General + Edit 우측 + 활성 상태 강화&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;020c509&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;generation prompt 입력창에 여백 추가 (multiline 친화)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;8208a11&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;library tab + prompt area zoom + transparent overlay&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;349d142&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;preview 모달에서 auto-pick 필터 re-roll, dead label 제거&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;a-z 단축키&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;A/B 비교용 화살표 + &amp;lsquo;a&amp;rsquo;,&amp;lsquo;b&amp;rsquo; 키보드 비교 (&lt;code&gt;fad542e&lt;/code&gt;)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;마지막 키보드 비교 단축키가 의외로 좋았다. 마우스로 두 결과 사이를 왔다갔다 하다가 &amp;lsquo;a&amp;rsquo;/&amp;lsquo;b&amp;rsquo; 키만 누르면 토글 — 비교 평가 속도가 체감 2배.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;p&gt;dev #17에서 #18로 오면서 &lt;strong&gt;추상화를 줄이는 게 진보였다.&lt;/strong&gt; &amp;ldquo;tone injection mode&amp;quot;라는 5축짜리 추상화는 사용자에게 코드 모델을 강요하는 거였고, 실제 mental model은 &amp;ldquo;사람을 넣을지 / 물건을 넣을지&amp;rdquo; 둘 중 하나다. 모델/제품 두 탭으로 줄인 게 정답.&lt;/p&gt;
&lt;p&gt;OpenAI 사이드 B 라우팅도 같은 결의 결정. 단일 모델로 evaluation을 추측하는 것보다 두 모델 응답을 나란히 보고 키보드로 토글하는 게 빠르다. &lt;code&gt;asyncio.gather&lt;/code&gt; shield 같은 디테일이 신경 쓰이지만, 한쪽이 죽었을 때 어떻게 처리할지 명시적으로 정해두면 같은 패턴을 재사용할 수 있다.&lt;/p&gt;
&lt;p&gt;권한 게이트는 의외로 작은 변화로 큰 효과를 본 케이스. &lt;code&gt;is_internal&lt;/code&gt; 컬럼 하나 + UI에서 conditional rendering, 그러면 internal-only S3 admin이나 tone-lock 같은 기능을 메인 코드베이스에 두면서도 외부 사용자에게는 안 보이게 할 수 있다. 별도 admin 앱으로 빼지 않은 게 비용을 크게 아꼈다.&lt;/p&gt;
&lt;p&gt;다음 dev #19에서 다룰 것: gpt-image-2의 quality A/B 결과 정리, 모델 라이브러리에 group 개념(여러 사람 한 번에) 추가, internal tone-lock을 external로 풀어줄 조건.&lt;/p&gt;</description></item><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>