<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Idempotency on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/ko/tags/idempotency/</link><description>Recent content in Idempotency on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Thu, 28 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/ko/tags/idempotency/index.xml" rel="self" type="application/rss+xml"/><item><title>popcon 개발일지 #13 — 더블 클릭 dedup, 오프-아워 플래그 TTL 자가 치유, 그리고 두 개의 production 균열 메우기</title><link>https://ice-ice-bear.github.io/ko/posts/2026-05-28-popcon-dev13/</link><pubDate>Thu, 28 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-05-28-popcon-dev13/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post popcon 개발일지 #13 — 더블 클릭 dedup, 오프-아워 플래그 TTL 자가 치유, 그리고 두 개의 production 균열 메우기" /&gt;&lt;h2 id="개요"&gt;개요
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-05-11-popcon-dev12/" &gt;이전 글: #12 — 다운로드 zip 스트리밍, 액션 캐시 SQLite 영속화, 그리고 크레딧 표시&lt;/a&gt;를 17일 전에 올렸다. 그 사이 다섯 개의 작은 커밋이 들어갔다 — 하지만 모두 production 인시던트에서 나왔다.&lt;/p&gt;
&lt;p&gt;가장 큰 건 retry/regenerate/approve의 3계층 dedup. 두 번째는 off-hours 플래그에 TTL을 붙여 누락된 cron이 &lt;code&gt;/api/generate-set&lt;/code&gt;를 429로 막는 대신 자가 치유되게 한 것. 나머지 셋은 정밀 수정 — 비정규화 캐시가 비었을 때 아카이브 카드 썸네일 폴백, 크레딧 pill의 stale-while-revalidate 캐싱, 그리고 풀바디 포즈 생성기의 한 단어 프롬프트 정밀화.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["popcon dev #12 (20fc24c)"] --&gt; M1["더블 클릭 dedup &amp;lt;br/&amp;gt; SET-NX claim + 상태 사전 플립 + optimistic UI"]
 M1 --&gt; M2["off-hours 플래그 TTL &amp;lt;br/&amp;gt; 누락 cron 자가 치유"]
 M2 --&gt; M3["아카이브 썸네일 폴백 &amp;lt;br/&amp;gt; 캐시 비면 emoji_results[0]"]
 M3 --&gt; M4["크레딧 pill SWR &amp;lt;br/&amp;gt; localStorage 캐시 + 스켈레톤"]
 M4 --&gt; M5["풀바디 포즈 프롬프트 &amp;lt;br/&amp;gt; head-to-toe, 신발 말고 발"]
 M5 --&gt; End["popcon dev #13 (1da0a74)"]&lt;/pre&gt;&lt;p&gt;다섯 커밋, 한 가지 관통하는 주제 — &lt;strong&gt;여기 모든 수정은 지난 2주 동안 실제로 떨어진 production page에 대한 반응이다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="retry--regenerate--approve-3계층-dedup"&gt;Retry / Regenerate / Approve 3계층 Dedup
&lt;/h2&gt;&lt;p&gt;가장 큰 커밋(&lt;code&gt;1da0a74&lt;/code&gt;)이 이 윈도우에서 가장 비싼 버그였다 — Retry, Regenerate, Approve 버튼 빠른 더블 클릭이 매 클릭마다 크레딧을 차감하고 새 GPU 잡을 dispatch하고 있었다. popcon의 잡당 비용에서 사용자당 의도하지 않은 클릭 두 번은 실제 돈이다.&lt;/p&gt;
&lt;p&gt;단일 status 체크 패턴으로는 부족했다. 작지만 안정적으로 맞는 레이스 윈도우가 있었기 때문이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;T0: 사용자가 Retry 클릭 — 핸들러가 status=&amp;#39;pending&amp;#39; 읽고, 과금 + dispatch 진행
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;T0+5ms: 사용자가 Retry 다시 클릭 — 두 번째 핸들러가 status=&amp;#39;pending&amp;#39; 읽고(워커가 아직 안 뒤집음) 진행
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;T0+50ms: 워커가 잡 1 픽업, &amp;#39;running&amp;#39;으로 플립
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;T0+55ms: 워커가 잡 2 픽업, 다시 &amp;#39;running&amp;#39;으로 플립 — 둘 다 이미 결제됨
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;세 계층이 이를 닫았다.&lt;/p&gt;
&lt;h3 id="계층-1--원자적-redis-set-nx-claim"&gt;계층 1 — 원자적 Redis SET-NX claim
&lt;/h3&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;# backend/job_store.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;try_claim_emoji_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&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;SET key value NX EX 30 원자적 claim. True = 획득, False = 중복.&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;popcon:claim:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;code&gt;SET key value NX EX 30&lt;/code&gt;은 &amp;ldquo;먼저 온 사람이 이긴다, 30초 후 만료&amp;quot;의 Redis 정전 프리미티브다. claim은 과금 &lt;em&gt;전에&lt;/em&gt; 잡고 핸들러 전체에 걸쳐 유지한다. 30초 TTL은 안전망 — 핸들러가 도중에 죽으면 claim이 자가 클리어되어 사용자가 30초 안에 재시도할 수 있다.&lt;/p&gt;
&lt;h3 id="계층-2--과금-후-동기적-상태-사전-플립"&gt;계층 2 — 과금 후 동기적 상태 사전 플립
&lt;/h3&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;# backend/main.py — /retry, /regenerate, /approve 안&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/retry/&lt;/span&gt;&lt;span class="si"&gt;{job_id}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_emoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;try_claim_emoji_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;retry-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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="s2"&gt;&amp;#34;status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;duplicate&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deduped&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&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;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emoji_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;failed&amp;#34;&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="s2"&gt;&amp;#34;status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wrong_state&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deduped&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&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;await&lt;/span&gt; &lt;span class="n"&gt;charge_credits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RETRY_COST&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="c1"&gt;# 동기 사전 플립 — 프론트엔드의 다음 폴이 즉시 &amp;#39;pending&amp;#39;을 본다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mark_emoji_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&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;await&lt;/span&gt; &lt;span class="n"&gt;retry_emoji_task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&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;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&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="c1"&gt;# dispatch 실패 시 보상 롤백&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;refund_credits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RETRY_COST&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;await&lt;/span&gt; &lt;span class="n"&gt;release_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;retry-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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;raise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;사전 플립이 미묘한 조각이다. 이게 없으면 프론트엔드의 다음 폴(1-3초 후)이 여전히 status=&amp;lsquo;failed&amp;rsquo;를 본다. Celery 워커가 아직 픽업하지 않았기 때문이다 — 그래서 사용자가 Retry를 &lt;em&gt;또&lt;/em&gt; 누르고, 여전히 실패 상태 체크를 통과해서, 워커가 플립하기 전에 두 번째로 통과해버린다. 핸들러 안에서 동기적으로 플립하면 그 구멍이 막힌다.&lt;/p&gt;
&lt;h3 id="계층-3--optimistic-프론트엔드-패치--busy-가드"&gt;계층 3 — Optimistic 프론트엔드 패치 + busy 가드
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// frontend/components/FramesAnimatePanel.tsx
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRetry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// React 커밋 레이스 가드
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;setBusy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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="nx"&gt;patchResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pending&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// optimistic — await 전
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&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;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idx&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;patchResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;failed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;: &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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="nx"&gt;setBusy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="p"&gt;}&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;if (busy) return&lt;/code&gt; 가드는 React 커밋 레이스를 닫는다 — &lt;code&gt;setBusy(true)&lt;/code&gt;가 dispatch되고 다음 렌더가 커밋되기 전 사이에 두 번째 클릭이 통과할 수 있다. 백엔드 계층과 결합되면, 이건 세 개의 독립적인 dedup 지점이 된다. 하나가 실패해도 중복 잡을 만들기에는 부족하다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/approve&lt;/code&gt; 엔드포인트는 런치 때부터 이 패턴을 부분적으로 갖고 있었다(커밋 본문에 &amp;ldquo;Frontend double-clicks used to spawn parallel Wan calls&amp;quot;라고 적혀 있다) — 하지만 status 체크만으로는 racy했다. 이제 같은 패턴이 균일하게 적용된다.&lt;/p&gt;
&lt;h3 id="보상-롤백"&gt;보상 롤백
&lt;/h3&gt;&lt;p&gt;또 다른 production 함정이 fly.io의 SIGTERM 동작이었다. &lt;code&gt;charge_credits&lt;/code&gt;와 &lt;code&gt;retry_emoji_task.delay()&lt;/code&gt; 사이에서 핸들러가 죽으면, 사용자는 결제됐는데 작업은 스케줄되지 않는다. &lt;code&gt;try/except&lt;/code&gt;가 이제 재발생 전에 결제를 환불하고 claim을 해제하므로, 핸들러 중간 크래시가 사용자를 출발선으로 되돌려놓는다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;test_retry_idempotency.py&lt;/code&gt;의 테스트 커버리지는 9개 케이스 — 동시 더블 클릭, 상태 기반 dedup, claim 프리미티브 시맨틱, &lt;code&gt;.delay()&lt;/code&gt; 실패 롤백, approve-all 벌크 dedup.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="off-hours-플래그-ttl--cron-스킵에서-자가-치유"&gt;Off-Hours 플래그 TTL — Cron 스킵에서 자가 치유
&lt;/h2&gt;&lt;p&gt;커밋 &lt;code&gt;4d131ba&lt;/code&gt;는 실제 인시던트에서 나왔다. 매일 저녁 evening-down GitHub Actions cron이 14:55 UTC에 &lt;code&gt;popcon:off_hours=true&lt;/code&gt;를 세팅해서 비싼 작업을 off-hour 동안 비활성화하고, evening-up cron이 09:30 UTC에 클리어한다. 2026-05-11에 아침 cron이 심각하게 지연됐고(GHA scheduled trigger는 부하 아래 가끔 스킵된다), 누군가 수동으로 플래그를 클리어할 때까지 &lt;code&gt;/api/generate-set&lt;/code&gt;가 몇 시간 동안 429를 반환했다.&lt;/p&gt;
&lt;p&gt;수정 — 플래그 자체에 TTL.&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;# backend/job_store.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_OFF_HOURS_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;# 20시간&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_off_hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&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;if&lt;/span&gt; &lt;span class="n"&gt;value&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;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;popcon:off_hours&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_OFF_HOURS_TTL_SECONDS&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;else&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;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;popcon:off_hours&amp;#34;&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;두 쓰기 경로 모두 TTL을 받았다 — 백엔드 어드민 엔드포인트(&lt;code&gt;job_store.py&lt;/code&gt;)와 스케줄러 GHA 경로(&lt;code&gt;.github/scripts/scheduler.py&lt;/code&gt;). 의도된 off-window가 18h 35m(14:55 → 09:30 UTC)이니 20h가 cron 지연을 위한 ~85분 버퍼를 준다. evening-up이 완전히 누락돼도 플래그가 다음 날 ~10:55 UTC경 자가 클리어된다 — API를 무기한 막는 대신.&lt;/p&gt;
&lt;p&gt;회귀 테스트는 &lt;code&gt;set_off_hours(True)&lt;/code&gt;가 &lt;code&gt;set(...)&lt;/code&gt;을 &lt;code&gt;ex=_OFF_HOURS_TTL_SECONDS&lt;/code&gt;로 호출하는지 어서트한다. 수정 전에는 &lt;code&gt;Expected: set('popcon:off_hours', 'true', ex=72000); Actual: set('...', 'true')&lt;/code&gt;로 실패한다 — 조용히 회귀시키는 게 불가능해야 하는 정확한 모양의 버그.&lt;/p&gt;
&lt;p&gt;이 수정의 모양을 짚어둘 만하다 — &lt;strong&gt;플래그를 세팅하는 시스템과 클리어하는 시스템이 다르고(어드민 엔드포인트 vs 스케줄러 cron), 그래서 독립적으로 실패할 수 있다. TTL이 둘을 잇는다.&lt;/strong&gt; 두 독립적으로 실패하는 시스템 사이에 생애주기가 걸쳐 있는 플래그는 모두 최대 허용 wedge 시간으로 묶인 TTL을 가져야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="아카이브-카드-썸네일-폴백"&gt;아카이브 카드 썸네일 폴백
&lt;/h2&gt;&lt;p&gt;커밋 &lt;code&gt;252569f&lt;/code&gt;. 아카이브 리스트가 일부 잡에서 깨진 이미지를 렌더링하고 있었다.&lt;/p&gt;
&lt;p&gt;근본 원인 — &lt;code&gt;jobs.thumbnail_path&lt;/code&gt;와 &lt;code&gt;jobs.thumbnail_key&lt;/code&gt;는 벌크 start-frame 태스크가 채우는 비정규화 캐시다. SQLite 레이스(이모지 인덱스 0 — 썸네일 소스 — 동시 regenerate) 아래서 벌크 태스크가 두 컬럼 모두 쓰지 못할 수 있다. 둘 다 비면, &lt;code&gt;list_jobs&lt;/code&gt;가 &lt;code&gt;/api/job/{id}/reference&lt;/code&gt;를 반환했다 — &lt;em&gt;죽은&lt;/em&gt; URL, 서빙할 행이 없는 — 그리고 아카이브 카드가 깨진 이미지를 렌더했다.&lt;/p&gt;
&lt;p&gt;캐시가 비어 있으면 &lt;code&gt;emoji_results[0]&lt;/code&gt;을 정전 소스로 읽도록 읽기 경로를 바꿨다.&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;# backend/db/operations.py&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;get_thumbnail_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_path&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="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_path&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_key&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="n"&gt;r2_signed_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_key&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="c1"&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;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emoji_results&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emoji_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&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="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emoji_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&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="n"&gt;PLACEHOLDER_THUMBNAIL_URL&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;비정규화 캐시는 남는다 — 쿼리 성능 때문에 있으니 — 하지만 폴백 체인 덕에 캐시 쓰기 실패가 더 이상 깨진 UI로 표면화되지 않는다. 끝의 placeholder가 바닥이다 — 이모지 결과가 아직 없는 신선한 잡도 뭔가는 렌더한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="크레딧-pill-stale-while-revalidate"&gt;크레딧 Pill: Stale-While-Revalidate
&lt;/h2&gt;&lt;p&gt;커밋 &lt;code&gt;5f785aa&lt;/code&gt;. 헤더의 크레딧 잔액 pill이 매 리로드마다 API 호출이 진행 중인 동안 깜빡였다 — 작지만 끈질긴 UX 짜증.&lt;/p&gt;
&lt;p&gt;두 가지 연결된 수정:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// frontend/components/CreditPill.tsx
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;CreditPill() {&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;popcon:credits&amp;#34;&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="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setBalance&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;number&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;null&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getCredits&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nx"&gt;setBalance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;popcon:credits&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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="p"&gt;});&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="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;PulsingSkeleton&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;{&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;}&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&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;캐시된 값을 즉시 렌더, 네트워크가 돌아오면 신선한 값으로 교체 — 클래식 stale-while-revalidate. 진짜 첫 방문(캐시 없음)에는 펄싱 스켈레톤을 보여서 옆의 &lt;code&gt;SignInButton&lt;/code&gt;이 잔액 도착 시 튀지 않게 한다. 동반 &lt;code&gt;AuthProvider.tsx&lt;/code&gt; 변경은 사인 아웃 시 캐시가 무효화되도록 해서, 다른 사용자가 이전 사용자의 잔액을 잠깐이라도 보지 않게 했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="풀바디-포즈-프롬프트--head-to-toe-신발-말고-발"&gt;풀바디 포즈 프롬프트 — Head-to-Toe, 신발 말고 발
&lt;/h2&gt;&lt;p&gt;커밋 &lt;code&gt;cad14bc&lt;/code&gt;은 한 줄짜리 변경에 비해 효과가 컸다. 풀바디 포즈 생성기가 자주 발을 잘라내거나 — 사용자가 맨발을 원할 때 신발을 렌더하는 이미지를 만들고 있었다. 프롬프트 문구 조정:&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;# backend/pipeline/pose_generator.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# before&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;full body shot of the subject&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# after&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;head-to-toe shot of the subject, feet visible (not shoes)&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&amp;ldquo;Head-to-toe&amp;quot;가 디퓨전 모델에는 &amp;ldquo;full body&amp;quot;보다 더 문자적이다 — 모호한 의도가 아니라 명시적인 프레이밍 지시로 해석한다. &amp;ldquo;Feet visible (not shoes)&amp;ldquo;가 수정의 후반부였다 — &amp;ldquo;full body&amp;rdquo; 프롬프트는 발이 의도됐을 때 기본값으로 신발이 나오고 있었다.&lt;/p&gt;
&lt;p&gt;이 윈도우에서 가장 작은 커밋이지만 — &amp;ldquo;왜 항상 신발이 나오나요?&amp;ldquo;라는 불평의 홍수를 닫는 종류의 변경이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="커밋-로그"&gt;커밋 로그
&lt;/h2&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;2026-05-13&lt;/td&gt;
 &lt;td&gt;fix: dedup retry/regen/approve to prevent duplicate-click GPU spend&lt;/td&gt;
 &lt;td&gt;4개 파일, +664/-37&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-11&lt;/td&gt;
 &lt;td&gt;fix(off-hours): add 20h TTL to popcon:off_hours flag&lt;/td&gt;
 &lt;td&gt;3개 파일, +48/-3&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-11&lt;/td&gt;
 &lt;td&gt;fix(archive): fall back to first emoji row when jobs.thumbnail_* is empty&lt;/td&gt;
 &lt;td&gt;2개 파일, +22/-2&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-11&lt;/td&gt;
 &lt;td&gt;fix(credits): cache balance in localStorage, show skeleton on first paint&lt;/td&gt;
 &lt;td&gt;2개 파일, +57/-9&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-11&lt;/td&gt;
 &lt;td&gt;fix(pose): tighten full-body prompt wording (head-to-toe, feet not shoes)&lt;/td&gt;
 &lt;td&gt;1개 파일, +1/-1&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;p&gt;다섯 커밋, 네 개의 production 균열. 그중 두 개 — dedup과 off-hours TTL — 가 깊은 모양을 공유한다. &lt;strong&gt;시스템이 실패하는 이유는 lockstep으로 가정된 두 컴포넌트가 실제로는 그렇지 않기 때문이다.&lt;/strong&gt; retry 핸들러는 워커가 이미 상태를 플립했다고 가정했다 — 아니었다. off-hours-set cron은 off-hours-clear cron이 항상 제때 돌 거라 가정했다 — 아니었다.&lt;/p&gt;
&lt;p&gt;첫 번째 실패 모드는 동기 직렬화(claim + 사전 플립)로 수정된다. 두 번째는 타임아웃(TTL)로 수정된다. 둘 다 같은 모양이다 — 이전에 희망을 통해 암묵적으로 조율되던 두 시스템 사이의 격차를 명시적으로 처리하는 것.&lt;/p&gt;
&lt;p&gt;아카이브 썸네일과 크레딧 pill 수정도 더 작은 스케일의 같은 아이디어다 — 비정규화 캐시와 네트워크 호출이 best-effort로 다뤄져야 했는데 정전으로 다뤄지고 있었다. 명시적 폴백(emoji_results[0])과 stale-while-revalidate 패턴(localStorage 캐시)을 추가하니 optimistic 경로가 여전히 빠르되, 풀리지 않을 때의 실패 모드는 사라졌다.&lt;/p&gt;
&lt;p&gt;포즈 프롬프트 수정은 아웃라이어지만 — 같은 요점을 미니어처로 만들기 때문에 이 포스트에 넣을 가치가 있다. 모호한 프롬프트(&amp;ldquo;full body&amp;rdquo;)가 디퓨전 모델의 의도 &lt;em&gt;해석&lt;/em&gt;에 의존했다. 그걸 문자적 프레이밍 지시로 교체하니 해석 단계가 사라졌다 — 정확히 암묵적 조율을 명시적 직렬화로 교체하는 것과 같다.&lt;/p&gt;
&lt;p&gt;다음 세션 — 벌크 액션 dedup도 같은 감사를 받아야 한다(&lt;code&gt;/approve-all&lt;/code&gt;은 dedup이 있지만 optimistic-frontend 계층이 없다). 그리고 애초에 &lt;code&gt;jobs.thumbnail_*&lt;/code&gt;를 비게 만든 벌크 start-frame 태스크의 SQLite 레이스를 추적해서 근원에서 고쳐야 한다.&lt;/p&gt;</description></item></channel></rss>