<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko">
  <generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator>
  <link href="https://sasostudio.dev/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://sasostudio.dev/" rel="alternate" type="text/html" hreflang="ko" />
  <updated>2026-10-14T03:16:58+09:00</updated>
  <id>https://saso-studio.vercel.app/feed.xml</id>

  
  
    <title type="html">Saso Studio</title>
  

  
    <subtitle>게임과 AI 에이전트 도구를 만들어 출시하고, 만드는 과정을 전부 공개하는 개발 스튜디오입니다. Saso Studio — games, agents, and AI tooling, built in public.</subtitle>
  

  
    <author>
        <name>CHPARK03</name>
      
      
    </author>
  

  
  
  
  
  
    <entry>
      

      <title type="html">Godot + AI 조합 평가</title>
      <link href="https://sasostudio.dev/2026/09/30/godot-ai-stack-evaluation/" rel="alternate" type="text/html" title="Godot + AI 조합 평가" />
      <published>2026-09-30T00:00:00+09:00</published>
      <updated>2026-09-30T00:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/09/30/godot-ai-stack-evaluation</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/09/30/godot-ai-stack-evaluation/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>스팀에 낼 게임을 준비하면서 게임보다 먼저 답해야 할 질문이 있었다. <strong>앞으로 스팀 프로젝트를 Godot 엔진과 AI 코딩 에이전트(Claude) 조합으로 진행해도 되는가.</strong></p>

<p>이 질문에 답하려고 게임을 하나 만들었다. 이미 시장에서 검증된 턴제 심리전 게임의 규칙을 그대로 재현하고, 원작의 아트·사운드·이름은 쓰지 않았다. 모델과 사운드는 직접 만들거나 무료 에셋을 썼다. 게임 아이디어의 가치를 보려는 게 아니라 도구 조합의 역량만 보려는 테스트 케이스라서, 배포도 판매도 하지 않는 내부 평가용으로 두었다.</p>

<p>결론부터 적는다. <strong>이 조합으로 계속 간다.</strong> 9월 14일에 규칙을 프로토타입으로 먼저 검증하고, 15일부터 24일까지 열흘 동안 Godot으로 규칙 엔진부터 3D 연출, 사운드까지 완성했다. 직접 플레이하고 남긴 최종 평가는 «아주 훌륭하게 끝난 듯»이었다. 다만 가는 길에 이 조합이 어디서 거짓 신호를 내는지도 여러 번 확인했다. 이 글은 그 기록이다.</p>

<h2 id="무엇으로-판정했나">무엇으로 판정했나</h2>

<p>평가 축은 처음에 셋으로 고정했다.</p>

<table>
  <thead>
    <tr>
      <th>축</th>
      <th>보는 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>에이전트의 기술력</td>
      <td>설계·구현·검증을 실제로 해내는가. 버그를 스스로 찾는가. 실측 없이 “됐다”고 하지 않는가</td>
    </tr>
    <tr>
      <td>Godot 활용</td>
      <td>씬·노드·시그널·입력·3D·오디오 같은 엔진 고유 영역을 제대로 쓰는가</td>
    </tr>
    <tr>
      <td>결과물 만족도</td>
      <td>내가 직접 해 봐서 조작·템포·연출이 괜찮은가</td>
    </tr>
  </tbody>
</table>

<p>여기에 기록할 지표로 <strong>사람 개입 횟수</strong>, <strong>에이전트가 막힌 지점과 유형</strong>, <strong>규칙 변경부터 검증까지 걸리는 시간</strong>, <strong>자동 검증이 실패를 실패로 잡는지</strong>를 붙였다.</p>

<h2 id="도구-구성">도구 구성</h2>

<table>
  <thead>
    <tr>
      <th>역할</th>
      <th>도구</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>엔진</td>
      <td>Godot 4.7.2 표준판 (GDScript)</td>
    </tr>
    <tr>
      <td>테스트</td>
      <td>GUT 9.7.1, 헤드리스 실행</td>
    </tr>
    <tr>
      <td>3D 모델</td>
      <td>Blender 4.5 LTS + Blender MCP (에이전트가 스크립트로 모델링), 일부 Poly Haven 에셋</td>
    </tr>
    <tr>
      <td>영상 분석</td>
      <td>ffmpeg</td>
    </tr>
    <tr>
      <td>사운드</td>
      <td>외부 음원 0. 효과음 35종과 음악 8곡을 전부 코드로 파형 합성</td>
    </tr>
  </tbody>
</table>

<p>처음 계획은 Unity였다. 새 리서치 결과를 받고 Godot으로 바꿨는데, 바꾸면서 가장 먼저 걸린 건 엔진 기능이 아니라 <strong>사라진 안전장치</strong>였다. 예전 엔진에서는 규칙을 어기면 컴파일러가 빌드를 막았다. 새 엔진은 그냥 돌아간다. 조용히 무너지면 자동화 루프가 의미를 잃기 때문에, 컴파일러가 하던 일을 테스트로 대신 세웠다.</p>

<p>비슷한 오해도 하나 미리 막았다. Godot의 씬 파일은 텍스트라서 사람이 읽을 수 있다. 그렇다고 에이전트가 마음대로 고쳐도 되는 건 아니다. 내부 식별자 때문에 손으로 고치면 깨진다. 읽힌다는 것과 고쳐도 된다는 것은 다른 말이라서, 이 구분을 작업 문서에 못 박아 두었다. 자동화 플래그 중 셋이 에디터 전용이라 출시용 실행 파일에는 없다는 것도 이때 확인했다.</p>

<h2 id="규칙부터-증명했다">규칙부터 증명했다</h2>

<p>화면을 만들기 전에 규칙이 맞는지부터 확인했다. 화면이 붙으면 버그가 규칙에서 났는지 연출에서 났는지 가르기 어려워진다.</p>

<p>9월 14일, 화면 의존이 전혀 없는 JS 프로토타입으로 규칙을 짜고 네 가지 대진 조건으로 합계 48,000판을 자동으로 돌렸다. 크래시와 불변식 위반은 0건이었다. 다음 날 같은 규칙을 GDScript로 <strong>따로 다시 구현</strong>하고 그중 한 조건을 골라 대조했다.</p>

<table>
  <thead>
    <tr>
      <th>조건</th>
      <th style="text-align: right">3라운드를 모두 이길 확률</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이론값 (실력이 같으면 라운드마다 1/2)</td>
      <td style="text-align: right">12.5%</td>
    </tr>
    <tr>
      <td>JS · 추론 AI 대 추론 AI</td>
      <td style="text-align: right">12.6%</td>
    </tr>
    <tr>
      <td>JS · 무작위 대 무작위</td>
      <td style="text-align: right">12.2%</td>
    </tr>
    <tr>
      <td>Godot · 무작위 대 무작위 (5,000판, 429ms)</td>
      <td style="text-align: right">13.0%</td>
    </tr>
  </tbody>
</table>

<p>실력이 같은 대진은 두 구현 모두 이론값 근처에 모였다. 무작위 대 무작위라는 같은 조건에서 독립된 두 구현이 12.2%와 13.0%를 냈으니, 한쪽에만 있는 왜곡이라면 이렇게 맞기 어렵다. JS 측정에서는 무작위 플레이어가 추론 AI를 상대하면 클리어율이 0.1%로 떨어졌다. 확률 추론이 이 게임의 핵심 실력이라는 뜻이다. 이 교차 검증까지는 내가 기술적으로 개입한 횟수가 0회였다.</p>

<p>난이도는 한 번 틀리게 읽었다. 처음 측정에서는 라운드별 승률이 49.6% → 48.4% → 50.9%로 거의 같아서, 체력과 자원을 같이 늘리니 양쪽이 상쇄된다고 결론 냈다. 그래서 수치는 건드리지 않고 <strong>AI 상대의 행동만</strong> 단계별로 만들었다. 정보를 먼저 확인하는지, 아이템을 조합해서 쓰는지 같은 차이다. 같은 설정에서 AI 단계만 바꿔도 플레이어 승률이 89%에서 33%까지 움직였고, 단계를 라운드에 배치하니 57% → 50% → 35%로 곡선이 생겼다.</p>

<p>그런데 9월 19일 영상 대조(아래)에서 마지막 라운드의 체력 수치를 처음부터 잘못 잡고 있었다는 게 드러났다. 바로잡고 다시 재니 처음 분석 자체가 틀렸다. 상쇄되는 게 아니라, 라운드가 길어질수록 더 잘 두는 쪽으로 기울었다. 그 사이에 정한 “1라운드부터 아이템 지급” 결정까지 반영된 현행 곡선은 이렇다.</p>

<table>
  <thead>
    <tr>
      <th>라운드</th>
      <th style="text-align: right">1</th>
      <th style="text-align: right">2</th>
      <th style="text-align: right">3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>라운드 단독 클리어율 (AI 플레이어(레벨 2) 대 라운드별 AI, 4,000판씩)</td>
      <td style="text-align: right">79.0%</td>
      <td style="text-align: right">50.3%</td>
      <td style="text-align: right">29.7%</td>
    </tr>
  </tbody>
</table>

<p>이 곡선은 AI 단계 배치, 아이템 결정, 수치 정정 세 가지가 함께 만든 결과다. 틀린 옛 분석은 지우지 않고 “폐기”로 표시해 사양서에 남겨 두었다.</p>

<h2 id="자동화를-믿어도-되는가">자동화를 믿어도 되는가</h2>

<p>환경을 세우고 가장 먼저 한 일은 테스트 러너의 종료 코드를 실측하는 것이었다. 일부러 실패하는 테스트는 1, 통과하는 테스트는 0이 나왔다. 여기까지는 믿을 수 있었다.</p>

<p>문제는 그다음에 나왔다. <strong>테스트 파일이 파싱에 실패하면 GUT는 경고만 찍고 그 파일을 건너뛴 뒤 “All tests passed”와 종료 코드 0을 낸다.</strong> 종료 코드는 정직했지만 테스트가 아예 실행되지 않은 경우까지 막지는 못했다. 그래서 러너를 감싸는 스크립트가 출력에서 <code class="language-plaintext highlighter-rouge">Ignoring script</code>, <code class="language-plaintext highlighter-rouge">SCRIPT ERROR</code>, <code class="language-plaintext highlighter-rouge">Parse Error</code>를 찾으면 강제로 1을 반환하게 했다. 이 가드는 평가 기간에 최소 세 번 실제로 일했다.</p>

<p>그 밖에 이 조합에서 부딪힌 함정은 이렇다.</p>

<table>
  <thead>
    <tr>
      <th>증상</th>
      <th>원인</th>
      <th>성격</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>새로 만든 클래스를 테스트가 못 찾음</td>
      <td>전역 클래스 캐시는 <code class="language-plaintext highlighter-rouge">--import</code>를 돌려야 갱신된다</td>
      <td>Godot 고유</td>
    </tr>
    <tr>
      <td>초기화 함수 전체가 무력화됨</td>
      <td>노드 타입을 바꾸고 <code class="language-plaintext highlighter-rouge">@onready</code> 선언을 안 고침 → 첫 줄에서 멈추고 이후 참조가 전부 null</td>
      <td>GDScript 고유</td>
    </tr>
    <tr>
      <td>카메라가 천장을 봄</td>
      <td><code class="language-plaintext highlighter-rouge">Transform3D</code> 텍스트 표기는 행 우선인데 에이전트가 열 벡터로 적음</td>
      <td>Godot 고유</td>
    </tr>
    <tr>
      <td>같은 코드가 통과와 실패를 오감</td>
      <td>매번 무작위 시드로 시작하는데 “값이 줄었는가”만 단언</td>
      <td>테스트 설계 결함</td>
    </tr>
    <tr>
      <td>모델 일부 면의 재질이 어긋남</td>
      <td>Blender에서 면마다 찍은 재질 인덱스가 여러 연산을 거치며 몇 면씩 샘</td>
      <td>Blender 고유</td>
    </tr>
  </tbody>
</table>

<p>불안정한 테스트는 시작 상태를 고정하고 “정확히 1만큼 줄고 턴이 유지되는가”를 보도록 바꾼 뒤, 세 번 연속 같은 결과가 나오는지 확인했다. 재질 인덱스는 인덱스를 믿지 않기로 하고 “이 위치에 있으면 이 재질”이라는 기하 규칙으로 덮어쓴 다음, 눈으로 봤던 결함을 어서션으로 박았다. 그 어서션이 다음 빌드에서 오염된 면 10개를 바로 잡았다.</p>

<p>테스트는 24개에서 시작해 마지막에 141개가 됐고, 규칙을 건드릴 때마다 5,000판 자동 대전에서 불변식 위반 0건을 확인했다.</p>

<h2 id="규칙은-옮겼는데-경험은-안-옮겼다">규칙은 옮겼는데 경험은 안 옮겼다</h2>

<p>규칙과 AI가 끝나고 2D 화면을 붙여 “플레이 가능한 상태”를 만들었다. 한 판 해 보고 바로 알았다. 이건 채팅으로 하는 게임이었다. 원작은 공간과 물건을 직접 만지는 감각이 경험의 대부분인데 그걸 하나도 옮기지 않았다. 에이전트가 이 단계를 “UI”로 좁혀 해석했고, 그 좁은 범위를 충실히 지킨 결과였다.</p>

<p>그래서 3D로 전부 다시 만들었다. 이때 규칙 코드는 한 줄도 버리지 않았다. 규칙을 화면과 분리된 모듈로 먼저 짜 둔 덕분에 화면만 갈아 끼울 수 있었다. 기존 2D 화면은 디버그용으로 남겼고, 3D 조작을 얹는 동안에도 예전 버튼 경로를 살려 둬서 그 경로를 검증하던 테스트 15개가 계속 회귀를 잡았다.</p>

<p>사운드도 같은 방식으로 풀었다. 음원을 사 오지 않고 노이즈와 감쇠 곡선으로 파형을 합성했다. 라이선스 걱정이 없는 대신 헤드리스 환경에서는 들어 볼 수 없어서, 소리를 수치로 검사했다. 그 검사에서 효과음 두 개가 0dB에서 잘리고 있던 기존 결함이 나왔고(봉우리 값 32767), 정규화를 넣고 회귀 테스트에 추가했다.</p>

<h2 id="영상에서-연출을-뽑았다">영상에서 연출을 뽑았다</h2>

<p>연출을 맞추려면 원작이 실제로 어떻게 움직이는지 알아야 했다. 참고 영상 8개(12분, 2560×1440)를 에이전트에게 통째로 보여 주면 토큰이 감당이 안 된다. 그래서 ffmpeg로 프레임을 뽑아 4×4 격자 한 장에 16프레임씩 담은 컨택트시트로 훑고, 결정적인 순간만 원본 해상도로 다시 뽑았다. 프레임당 토큰이 13배 싸졌다.</p>

<p>첫 대조에서 규칙 수치 하나가 조사 자료와 다르다는 게 드러났다. 그 값에 기대던 밸런스 실측을 전부 무효로 돌리고 다시 쟀다. 검색으로 모은 규칙보다 실물이 싸고 정확하다는 걸 여기서 배웠다.</p>

<p>두 번째 교훈은 간격이었다.</p>

<table>
  <thead>
    <tr>
      <th>간격</th>
      <th>보인 것</th>
      <th>놓친 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2초</td>
      <td>장면 구성, 물건 배치, 규칙</td>
      <td>카메라 움직임, 템포</td>
    </tr>
    <tr>
      <td>0.25초</td>
      <td>카메라 5개 위치와 전환, 한 턴 5~6초, 아이템 사용 동작</td>
      <td>—</td>
    </tr>
    <tr>
      <td>0.125초 (밝기 곡선)</td>
      <td>순간적인 섬광, 화면 암전과 복귀</td>
      <td>—</td>
    </tr>
  </tbody>
</table>

<p>직접 해 보고 “너무 빠르다”고 느낀 게 계기였다. 2초 간격 시트로는 움직임이 아예 보이지 않았다. 0.25초로 다시 떠서 카메라와 템포를 전면 교체했고, 섬광처럼 순간적인 장면은 프레임별 평균 밝기 곡선에서 급등과 암전 구간을 찾아 시점을 쟀다. 이후로는 새 연출을 넣기 전에 해당 구간을 0.25초 간격으로 먼저 뜨는 것을 규칙으로 삼았다.</p>

<h2 id="테스트가-못-본-것">테스트가 못 본 것</h2>

<p>이 평가에서 가장 값진 데이터는 테스트가 전부 초록불일 때 사람이 찾은 버그들이다.</p>

<table>
  <thead>
    <tr>
      <th>증상</th>
      <th>그때 테스트</th>
      <th>원인</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>강화 아이템이 효과가 안 난 턴에도 풀리지 않아 두 턴 뒤까지 적용됨</td>
      <td>54/54 통과</td>
      <td>효과가 났을 때만 해제하고 있었다. 테스트도 그 경우만 봤다</td>
    </tr>
    <tr>
      <td>정보 아이템을 써도 결과가 안 보임</td>
      <td>통과</td>
      <td>“쓴 사람만 안다”를 구현하다 쓴 사람에게도 안 보여 줬다</td>
    </tr>
    <tr>
      <td>클릭해도 게임이 진행되지 않음</td>
      <td>129/129 통과</td>
      <td>화면을 덮는 UI 컨테이너가 기본 설정으로 마우스 입력을 먹고 있었다</td>
    </tr>
  </tbody>
</table>

<p>세 번째가 제일 극적이었다. 테스트는 전부 통과했는데, 실제 창에서 마우스로 누르면 아무 일도 일어나지 않았다. 원인은 실제 창에서 클릭을 재현해서 찾았다. 자동 검증은 내가 검증하려고 생각한 것만 본다. 헤드리스로는 화면을 볼 수 없어서 초반(9월 16일)에 화면을 PNG로 떠 주는 도구를 만들어 두었지만, 조작은 사람이 해 보는 수밖에 없었다. 마지막 두 차례 플레이 테스트에서 나온 지적 12건을 반영하고 평가를 끝냈다.</p>

<h2 id="판정">판정</h2>

<table>
  <thead>
    <tr>
      <th>축</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>에이전트의 기술력</td>
      <td>설계·구현·검증을 스스로 해냈다. 러너의 거짓 통과, 불안정한 테스트, 사운드 잘림 같은 결함도 스스로 찾았다. 다만 범위 해석이 좁으면 그대로 좁게 만들고, 사람이 조작해야 보이는 버그는 못 봤다</td>
    </tr>
    <tr>
      <td>Godot 활용</td>
      <td>씬 구성, 3D 조명과 카메라 전환, 후처리 셰이더, 오디오 버스, 마우스 입력까지 엔진 고유 영역을 두루 썼다. 부딪힌 함정은 전부 재현 조건과 함께 문서에 남겼다</td>
    </tr>
    <tr>
      <td>결과물 만족도</td>
      <td>플레이 테스트 지적을 반영한 뒤 «아주 훌륭하게 끝난 듯»</td>
    </tr>
  </tbody>
</table>

<p>그래서 다음 스팀 게임도 이 조합으로 간다. 대신 이번에 얻은 조건 세 가지를 처음부터 깔고 시작한다.</p>

<ul>
  <li>테스트 러너에는 <strong>건너뛴 파일을 실패로 만드는 가드</strong>를 기본으로 붙인다.</li>
  <li>계획표에 <strong>사람이 직접 한 판 하는 단계</strong>를 고정한다. 테스트 통과는 그 단계를 대신하지 못한다.</li>
  <li>연출은 <strong>0.25초 간격 대조</strong>부터 하고, 범위는 “UI”가 아니라 “경험”으로 적는다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>Before building a Steam game, I needed an answer to one question: <strong>should my Steam projects be built with Godot and an AI coding agent (Claude)?</strong></p>

<p>To answer it, I built a game. I reproduced the rules of an existing, market-proven turn-based mind game without using any of its art, audio, or names; models and sounds were made in-house or came from free asset libraries. The point wasn’t the game idea — only the toolchain was on trial — so it stayed internal: not released, not sold.</p>

<p>The verdict up front: <strong>I’m keeping this stack.</strong> After validating the rules in a prototype on Sept 14, it took ten days in Godot (Sept 15–24) to go from a rules engine to a finished 3D game with its own audio. The note I left after my final play-through: “looks like this ended really well.” Along the way, though, the stack gave me false signals more than once. That’s what this post is about.</p>

<h3 id="the-rubric">The rubric</h3>

<p>I fixed three axes before starting: the agent’s engineering (does it design, build, and verify on its own, find its own bugs, and never claim “done” without measuring?), use of Godot (scenes, nodes, signals, input, 3D, audio — the engine-specific parts), and whether the result actually feels good when I play it. I also tracked how often I had to step in, where the agent got stuck, how long a rule change took to verify, and whether automated checks caught failures as failures.</p>

<h3 id="the-toolchain">The toolchain</h3>

<table>
  <thead>
    <tr>
      <th>Role</th>
      <th>Tool</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Engine</td>
      <td>Godot 4.7.2 standard (GDScript)</td>
    </tr>
    <tr>
      <td>Tests</td>
      <td>GUT 9.7.1, headless</td>
    </tr>
    <tr>
      <td>3D models</td>
      <td>Blender 4.5 LTS + Blender MCP (the agent models via scripts), a few Poly Haven assets</td>
    </tr>
    <tr>
      <td>Video analysis</td>
      <td>ffmpeg</td>
    </tr>
    <tr>
      <td>Audio</td>
      <td>No external audio. 35 SFX and 8 music tracks, all synthesized in code</td>
    </tr>
  </tbody>
</table>

<p>The original plan was Unity; I switched to Godot after new research came in. The first thing that bit wasn’t a missing feature but a missing guardrail. In the old engine, breaking certain rules failed the build. In Godot the code just runs. A silent failure would hollow out the whole automation loop, so tests now do the job the compiler used to do. I also wrote down early that “scene files are readable text” doesn’t mean “the agent may hand-edit them” — internal IDs break when you do.</p>

<h3 id="prove-the-rules-first">Prove the rules first</h3>

<p>Before any visuals, I wanted proof the rules were right, because once a screen is attached you can no longer tell a rules bug from a presentation bug.</p>

<p>On Sept 14 I wrote the rules as a UI-free JS prototype and ran 48,000 automated games across four matchups: zero crashes, zero invariant violations. The next day the same rules were reimplemented independently in GDScript and checked against one of those matchups.</p>

<table>
  <thead>
    <tr>
      <th>Matchup</th>
      <th style="text-align: right">Chance of winning all 3 rounds</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Theory (equal skill, 1/2 per round)</td>
      <td style="text-align: right">12.5%</td>
    </tr>
    <tr>
      <td>JS · reasoning AI vs reasoning AI</td>
      <td style="text-align: right">12.6%</td>
    </tr>
    <tr>
      <td>JS · random vs random</td>
      <td style="text-align: right">12.2%</td>
    </tr>
    <tr>
      <td>Godot · random vs random (5,000 games, 429 ms)</td>
      <td style="text-align: right">13.0%</td>
    </tr>
  </tbody>
</table>

<p>Equal-skill matchups cluster around theory, and on the identical random-vs-random matchup two independent implementations gave 12.2% and 13.0%, which is hard to get if either one were quietly skewed. In the JS run, a random player cleared only 0.1% against the reasoning AI: probabilistic reasoning is the core skill of this game. I made zero technical interventions up to this point.</p>

<p>I misread the difficulty once. The first measurement had round win rates nearly flat (49.6% → 48.4% → 50.9%), and I concluded that scaling health and resources together cancels out. So I left the numbers alone and tiered only the <strong>AI opponent’s behavior</strong>: whether it gathers information first, whether it chains items. Changing just the AI tier moved the player’s win rate from 89% to 33%, and placing tiers per round produced a 57% → 50% → 35% curve.</p>

<p>Then the Sept 19 video comparison (below) showed I’d had the final round’s health value wrong from the start. With it corrected, the original analysis itself turned out wrong: longer rounds don’t cancel out, they tilt toward whoever plays better. Including a separate decision to hand out items from round 1, the current curve is:</p>

<table>
  <thead>
    <tr>
      <th>Round</th>
      <th style="text-align: right">1</th>
      <th style="text-align: right">2</th>
      <th style="text-align: right">3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Single-round clear rate (level-2 AI player vs per-round AI, 4,000 games each)</td>
      <td style="text-align: right">79.0%</td>
      <td style="text-align: right">50.3%</td>
      <td style="text-align: right">29.7%</td>
    </tr>
  </tbody>
</table>

<p>That curve comes from all three together: AI tiers, the item decision, and the corrected value. The wrong analysis stays in the spec, marked as discarded.</p>

<h3 id="can-the-automation-be-trusted">Can the automation be trusted?</h3>

<p>First thing after setup, I measured the test runner’s exit codes: 1 for a deliberately failing test, 0 for a passing one. So far, trustworthy.</p>

<p>Then: <strong>when a test file fails to parse, GUT prints a warning, skips the file, and reports “All tests passed” with exit code 0.</strong> The exit code was honest; it just had no way to cover tests that never ran. I wrapped the runner so that <code class="language-plaintext highlighter-rouge">Ignoring script</code>, <code class="language-plaintext highlighter-rouge">SCRIPT ERROR</code>, or <code class="language-plaintext highlighter-rouge">Parse Error</code> in the output forces exit code 1. That guard fired for real at least three times during the evaluation.</p>

<p>Other traps worth knowing:</p>

<ul>
  <li>New classes invisible to tests until <code class="language-plaintext highlighter-rouge">--import</code> refreshes the global class cache.</li>
  <li>Changing a node’s type without updating its <code class="language-plaintext highlighter-rouge">@onready</code> declaration kills the whole init function on line one; every later reference is null.</li>
  <li><code class="language-plaintext highlighter-rouge">Transform3D</code> in text form is row-major; the agent wrote columns and the camera stared at the ceiling.</li>
  <li>A test that flipped between pass and fail because games start from a random seed and it only asserted “the value went down.” Fixed by pinning the starting state and asserting an exact decrement, then requiring three identical runs.</li>
  <li>Per-face material indices set in Blender leaked a few faces through later operations. I stopped trusting indices, reassigned materials from geometric rules, and turned the defect I’d seen by eye into an assertion — which caught 10 contaminated faces on the very next build.</li>
</ul>

<p>Tests grew from 24 to 141, and every rule change was followed by 5,000 automated games with zero invariant violations.</p>

<h3 id="i-ported-the-rules-not-the-experience">I ported the rules, not the experience</h3>

<p>With rules and AI done, I put a 2D UI on it and played. It was a chat game. The original’s whole experience, the feel of handling objects in a physical space, hadn’t been ported at all. The agent had read that step as “UI” and built exactly that narrow scope.</p>

<p>So everything was rebuilt in 3D, without discarding a single line of rules code. Because the rules lived in a module separate from presentation, only the screen had to change. The 2D screen stayed as a debug view, and the old button path was kept alive under the 3D controls so the 15 tests built on it kept catching regressions.</p>

<p>Audio followed the same approach: waveforms synthesized from noise and envelopes. No licensing worries, but nothing can be heard in a headless run, so sounds are checked numerically. That check exposed an existing bug, two sound effects clipping at 0 dB (peak 32767), now normalized and covered by a regression test.</p>

<h3 id="extracting-motion-from-video">Extracting motion from video</h3>

<p>Feeding eight reference videos (12 minutes, 2560×1440) straight to the agent would blow the token budget. Instead ffmpeg pulls frames into 4×4 contact sheets, and only decisive moments get re-extracted at full resolution — about 13× cheaper per frame. The first pass also revealed that one rule value in my research was wrong, invalidating every balance measurement built on it. The real footage was cheaper and more accurate than search results.</p>

<p>The bigger lesson was sampling interval. At 2-second intervals I could see layout, objects, and rules — but no camera motion or pacing, which is exactly what felt “too fast” when I played. Re-sampling at 0.25 s gave five camera positions and their transitions, a 5–6 second turn, and the item animations. For split-second moments, I found the timing from per-frame average brightness (where a flash spikes, where the screen blacks out), measured in 0.125 s steps. Now every new piece of motion starts with a 0.25 s pass over the relevant footage.</p>

<h3 id="what-the-tests-couldnt-see">What the tests couldn’t see</h3>

<p>The most valuable data came from bugs found by a human while every test was green.</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Tests at the time</th>
      <th>Cause</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A buff item stayed active through a turn where it didn’t trigger, and applied two turns later</td>
      <td>54/54 passing</td>
      <td>It was only cleared when it triggered, and the tests only checked that case</td>
    </tr>
    <tr>
      <td>Using the info item showed nothing</td>
      <td>passing</td>
      <td>“Only the user knows” was implemented so that not even the user knew</td>
    </tr>
    <tr>
      <td>Clicking did nothing at all</td>
      <td>129/129 passing</td>
      <td>A full-screen UI container was swallowing mouse input by default</td>
    </tr>
  </tbody>
</table>

<p>The last one is the clearest: every test passed, while a real mouse in a real window did nothing. It was found by reproducing clicks in a real window. Automated checks only see what you thought to check. Headless runs can’t see the screen, so early on (Sept 16) I’d built a tool that renders it to PNGs, but controls still need someone to play. Twelve issues from the final two play-tests were fixed before I signed off.</p>

<h3 id="verdict">Verdict</h3>

<p>The agent designed, built, and verified on its own, and found its own defects — the runner’s false pass, the flaky test, the clipping audio. Its limits were equally clear: a narrow scope gets built narrowly, and bugs that only show up under a human’s hands stay invisible to it. Godot held up across scenes, 3D lighting and camera work, post-processing shaders, audio buses, and input, and every trap is documented with repro conditions.</p>

<p>So the next Steam game uses this stack, starting with three defaults from day one: a runner guard that fails on skipped files, a fixed “a human plays it” stage in every plan (passing tests never substitute for it), and motion work that begins with a 0.25 s video pass — scoped as “the experience,” not “the UI.”</p>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="build-in-public" />
      
        <category term="godot" />
      
        <category term="game-dev" />
      
        <category term="testing" />
      
        <category term="ai-agents" />
      

      
      
        <summary type="html"><![CDATA[스팀 게임을 Godot과 AI 코딩 에이전트로 만들어도 되는지 판정하려고, 검증된 턴제 게임의 규칙을 프로토타입으로 확인한 뒤 열흘 동안 Godot으로 재현했다. 규칙 시뮬레이션, 테스트 러너의 거짓 통과, 영상 프레임 대조, 사람이 한 판 해야 보이는 버그까지 정리한 평가 기록.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">뭉치뭉치 - 실시간 대전 동기화</title>
      <link href="https://sasostudio.dev/2026/08/28/realtime-versus-sync/" rel="alternate" type="text/html" title="뭉치뭉치 - 실시간 대전 동기화" />
      <published>2026-08-28T02:00:00+09:00</published>
      <updated>2026-08-28T02:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/28/realtime-versus-sync</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/28/realtime-versus-sync/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>싱글 플레이용으로 만든 물리 퍼즐 엔진에 1대1 실시간 대전을 붙였다. 봇전을 먼저 내고 사람 대 사람을 나중에 열었는데, <strong>엔진을 수정하지 않고</strong> 두 모드를 같은 코드로 돌린다.</p>

<p>승패 판정을 서버로 옮긴 이야기는 <a href="/2026/07/27/you-cant-report-your-own-win/">이전 글</a>에 썼다. 이 글은 그 아래 구조다.</p>

<h2 id="1-보드-1장--참가자-1명">1. 보드 1장 = 참가자 1명</h2>

<p>엔진의 진입점은 <code class="language-plaintext highlighter-rouge">createGame(canvas, skills, levels, opts)</code> 하나다. 캔버스 한 장을 받아 물리·상태·루프를 통째로 소유하고 핸들 객체를 반환한다.</p>

<p>대전은 별도 엔진을 만들지 않았다. <code class="language-plaintext highlighter-rouge">combatant.ts</code>가 <code class="language-plaintext highlighter-rouge">createGame()</code>을 감싸 <strong>대전용 오버라이드만 얹는다.</strong></p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">opts</code></th>
      <th>뜻</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">layout.*</code></td>
      <td>캔버스 안 컨테이너 기하</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">forcedZone</code></td>
      <td>중력·발사 방향 고정(<code class="language-plaintext highlighter-rouge">top</code>/<code class="language-plaintext highlighter-rouge">bottom</code>) — 대전 전용</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">logicalSize</code></td>
      <td>논리 좌표계 고정</td>
    </tr>
  </tbody>
</table>

<p>그래서 물리·합체·콤보·게임오버 로직이 두 모드에서 한 벌이다. 밸런스 조정 하나가 양쪽에 동시에 반영되고, 반대로 한쪽만 고치는 게 불가능하다는 뜻이기도 하다.</p>

<h2 id="2-opponent-계약--교체점-하나">2. <code class="language-plaintext highlighter-rouge">Opponent</code> 계약 — 교체점 하나</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opponent.ts        인터페이스(공격 이벤트 · 스냅샷 · fx)
 ├ botOpponent.ts     로컬 봇: 두 번째 물리 보드를 실제로 돌린다
 └ remoteOpponent.ts  원격: 물리를 돌리지 않는다 — 수신 스냅샷을 재렌더만 한다
</code></pre></div></div>

<p>총괄 상태머신(<code class="language-plaintext highlighter-rouge">matchController.ts</code>)은 <strong>상대가 봇인지 사람인지 모른다.</strong> 봇 → 원격 전환이 주입 교체 한 번이다.</p>

<p>이 설계 덕분에 대전을 단계로 출시할 수 있었다. 1단계는 봇전만 내고 게임성과 UI를 확정한다. 2단계에서 통신을 붙이는데, 이때 손대는 건 주입되는 구현체와 그 아래 통신 계층뿐이다.</p>

<p>한 가지 규칙이 이 구조를 지탱한다. <strong><code class="language-plaintext highlighter-rouge">versus/net/</code>은 격리 계층이고, 여기서 게임 로직을 참조하기 시작하면 교체점이 무너진다.</strong></p>

<h2 id="3-공격-모델은-순수함수로-떼어낸다">3. 공격 모델은 순수함수로 떼어낸다</h2>

<table>
  <thead>
    <tr>
      <th>파일</th>
      <th style="text-align: center">줄 수</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attackModel.ts</code></td>
      <td style="text-align: center">44</td>
      <td>콤보 × 병합 레벨 → 방해 개수. 순수함수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attackQueue.ts</code></td>
      <td style="text-align: center">108</td>
      <td>예고 지연 큐 + 상쇄(받는 쪽이 콤보를 내면 차감). 상태는 호출자 소유, 여기는 변환만</td>
    </tr>
  </tbody>
</table>

<p>밸런스 공식은 자주 바뀌고 <strong>틀려도 티가 안 난다.</strong> 부작용을 없애면 Node로 즉시 검증된다. 이 프로젝트는 테스트 러너를 쓰지 않고 <code class="language-plaintext highlighter-rouge">*.selfcheck.ts</code>를 순수 모듈 옆에 두고 직접 실행한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx tsx src/versus/net/matchResult.selfcheck.ts
</code></pre></div></div>

<p>브라우저·DOM·Firebase 의존이 있으면 대상이 안 된다. 그래서 <strong>검증 가능한 형태로 쪼개는 것이 테스트 인프라를 세우는 것보다 먼저</strong>가 된다. 경계값을 코드로 박아두면 밸런스 수치를 고칠 때 무엇이 깨지는지 즉시 나온다.</p>

<h2 id="4-스냅샷-스트림">4. 스냅샷 스트림</h2>

<table>
  <thead>
    <tr>
      <th>파일</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">snapshotStream.ts</code></td>
      <td>보드 상태 델타 인코딩 + 양자화</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">localSnapshotSource.ts</code></td>
      <td>내 보드 → 15Hz 송신</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">snapshotRenderer.ts</code></td>
      <td>수신 스냅샷 렌더</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rtdbRelay.ts</code></td>
      <td>중계</td>
    </tr>
  </tbody>
</table>

<p>두 가지가 대역폭을 결정한다.</p>

<p><strong>양자화가 정지 판정을 대신한다.</strong> 오브젝트가 멈췄는지 따로 계산하지 않는다. 양자화한 값이 직전과 같으면 그 오브젝트는 델타에서 자동으로 빠진다. 합체 퍼즐은 대부분의 오브젝트가 대부분의 시간 동안 쌓여 있으므로, 이것만으로 전송량이 크게 줄어든다.</p>

<p><strong>송신은 엔진을 수정하지 않고 붙였다.</strong> <code class="language-plaintext highlighter-rouge">localSnapshotSource</code>는 엔진이 이미 노출하고 있는 오브젝트 위치만 읽는 “읽기 탭”이다. 엔진 쪽에 송신용 훅을 뚫지 않았기 때문에 통신 기능이 추가돼도 싱글 플레이 경로의 동작은 변하지 않는다.</p>

<h2 id="5-원격-보드를-봇전과-똑같이-그리기">5. 원격 보드를 봇전과 똑같이 그리기</h2>

<p>원격 상대의 보드도 봇전과 <strong>완전히 같은</strong> 연출·프레임으로 그려야 한다. 다르면 사람들은 그걸 렉이나 버그로 읽는다.</p>

<p>그러려면 렌더 코드를 공유해야 하는데, <code class="language-plaintext highlighter-rouge">renderer.ts</code>는 원칙적으로 수정 금지 파일이다. 그래서 <strong>본문을 한 글자도 바꾸지 않고</strong> <code class="language-plaintext highlighter-rouge">fxRender.ts</code>·<code class="language-plaintext highlighter-rouge">frameRender.ts</code>로 옮겼다. diff 0을 검증 대상으로 명시하고, 새 파일 헤더에 왜 예외를 뒀는지 남겼다.</p>

<p>여기서 얻은 규칙: <strong>공유가 필요할 때 리팩토링과 기능 변경을 같은 커밋에 섞지 않는다.</strong> 섞으면 “동작이 달라진 게 이동 때문인지 변경 때문인지”를 나중에 못 가른다.</p>

<p>대가도 있다. 이 세 파일 중 하나를 고치면 싱글·봇전·원격 세 화면에 동시에 반영된다. 한 곳을 고치면 세 곳을 봐야 한다.</p>

<h2 id="6-화면-크기가-승패에-개입하지-않게">6. 화면 크기가 승패에 개입하지 않게</h2>

<p>물리 보드가 캔버스 픽셀에서 유도되면 <strong>큰 화면 = 큰 수용량</strong>이 된다. 합체 퍼즐에서 더 많이 담긴다는 건 곧 유리하다는 뜻이고, 대전에서는 불공정이다.</p>

<p>그래서 대전은 논리 치수를 고정한다(<code class="language-plaintext highlighter-rouge">360×390</code>). 물리는 그 위에서 돌고, 화면은 결과를 늘려서 보여주기만 한다. 창 크기를 바꿔도 물리적으로는 아무 일도 일어나지 않는다.</p>

<p>보드 위아래 배치를 바꾸는 기능도 같은 원칙으로 처리했다. 방향(중력·발사 원점·바구니 위치)이 정준 좌표계에 각인돼 있어서 DOM 이동만으로는 재배향이 불가능한데, 좌표계를 건드리면 상대 화면과 정산까지 영향이 간다. 그래서 <strong>캔버스 출력을 CSS <code class="language-plaintext highlighter-rouge">scaleY(-1)</code>로 뒤집고 텍스트만 미리 뒤집었다.</strong> 물리·스냅샷·네트워크는 무변경이다.</p>

<h2 id="7-로비와-재접속">7. 로비와 재접속</h2>

<p>방을 여는 것보다 <strong>양쪽이 진짜 준비됐는지</strong>가 어려웠다.</p>

<ul>
  <li>로비는 <strong>양측 ready + presence가 동시에 충족될 때만</strong> 시작한다. ready 플래그만 보면, 창을 닫아버린 사람의 ready가 그대로 남아 유령 상태로 대전이 시작된다.</li>
  <li>재입장은 <strong>같은 <code class="language-plaintext highlighter-rouge">clientId</code>만</strong> 허용한다. 방 선점의 원자성을 유지하기 위해서다.</li>
  <li>대전 중 새로고침 복귀는 1Hz 키프레임을 로컬로 복사해 두고, 복귀 시 엔진의 additive API로 보드를 재구성한다. 엔진 무수정 원칙의 명시적 예외 1건이다.</li>
</ul>

<p>이걸 만들다 설계 전제 하나가 실측으로 깨졌다. <strong>RTDB push key가 항상 증가한다고 가정했는데, 송신 측이 서버 시계 오프셋을 재보정하면 역행할 수 있다.</strong> 그래서 마지막으로 처리한 key를 단조 증가로만 갱신하는 게이트를 뒀다. 역행한 key가 들어오면 이미 처리한 것으로 취급된다.</p>

<h2 id="정리">정리</h2>

<ul>
  <li>두 모드를 하나의 엔진으로 돌리려면 엔진에 모드를 넣지 말고, 엔진을 감싸는 얇은 층에 오버라이드를 얹는다.</li>
  <li>봇과 원격을 같은 인터페이스로 만들면 단계 출시가 가능해진다. 그 대신 그 인터페이스 아래로 게임 로직이 새지 않게 지켜야 한다.</li>
  <li>공정성에 관계된 값은 화면에서 유도하지 않는다. 논리 치수를 고정하고 표시만 늘린다.</li>
  <li>코드를 옮기는 커밋과 동작을 바꾸는 커밋을 분리한다. 원본 대비 diff 0을 검증 항목으로 둘 수 있으면 그렇게 한다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>A physics puzzle engine originally built for single-player got 1v1 realtime versus bolted on. Bot matches shipped first, human-vs-human later, and both modes run on the same code <strong>without modifying the engine.</strong></p>

<p>The server-side outcome judging is covered in an <a href="/2026/07/27/you-cant-report-your-own-win/">earlier post</a>. This one is about the structure underneath it.</p>

<h3 id="one-board-equals-one-combatant">One board equals one combatant</h3>

<p>The engine has a single entry point: <code class="language-plaintext highlighter-rouge">createGame(canvas, skills, levels, opts)</code>. It takes one canvas, owns physics, state, and the loop, and returns a handle.</p>

<p>Versus mode is not a second engine. <code class="language-plaintext highlighter-rouge">combatant.ts</code> wraps <code class="language-plaintext highlighter-rouge">createGame()</code> and layers on <strong>only the versus overrides.</strong></p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">opts</code></th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">layout.*</code></td>
      <td>Container geometry inside the canvas</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">forcedZone</code></td>
      <td>Pin gravity and launch direction (<code class="language-plaintext highlighter-rouge">top</code>/<code class="language-plaintext highlighter-rouge">bottom</code>) — versus only</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">logicalSize</code></td>
      <td>Fixed logical coordinate space</td>
    </tr>
  </tbody>
</table>

<p>Physics, merging, combos, and game-over logic therefore exist once for both modes. A balance change lands on both at the same time — which also means changing one mode alone isn’t possible.</p>

<h3 id="the-opponent-contract">The <code class="language-plaintext highlighter-rouge">Opponent</code> contract</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opponent.ts        interface (attack events · snapshots · fx)
 ├ botOpponent.ts     local bot: actually runs a second physics board
 └ remoteOpponent.ts  remote: runs no physics — re-renders received snapshots
</code></pre></div></div>

<p>The match state machine (<code class="language-plaintext highlighter-rouge">matchController.ts</code>) <strong>does not know whether the opponent is a bot or a person.</strong> Switching from bot to remote is one injection swap.</p>

<p>That’s what made staged shipping possible. Phase one shipped bots only and locked down feel and UI. Phase two added networking, touching only the injected implementation and the transport layer below it.</p>

<p>One rule holds the structure up: <strong><code class="language-plaintext highlighter-rouge">versus/net/</code> is an isolation layer, and the moment it starts referencing game logic, the swap point collapses.</strong></p>

<h3 id="attack-model-as-pure-functions">Attack model as pure functions</h3>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th style="text-align: center">Lines</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attackModel.ts</code></td>
      <td style="text-align: center">44</td>
      <td>combo × merge level → garbage count. Pure</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">attackQueue.ts</code></td>
      <td style="text-align: center">108</td>
      <td>telegraph delay queue + cancellation (a combo on the receiving side subtracts). State is owned by the caller; this only transforms</td>
    </tr>
  </tbody>
</table>

<p>Balance formulas change often and <strong>fail silently when wrong.</strong> Removing side effects makes them verifiable from Node immediately. This project runs no test runner; <code class="language-plaintext highlighter-rouge">*.selfcheck.ts</code> files sit next to pure modules and execute directly.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx tsx src/versus/net/matchResult.selfcheck.ts
</code></pre></div></div>

<p>Anything depending on the browser, the DOM, or Firebase is out of scope — which means <strong>splitting code into a verifiable shape comes before standing up test infrastructure.</strong> With boundary values pinned in code, changing a balance number tells you immediately what it breaks.</p>

<h3 id="snapshot-stream">Snapshot stream</h3>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">snapshotStream.ts</code></td>
      <td>Board state delta encoding + quantization</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">localSnapshotSource.ts</code></td>
      <td>My board → 15Hz send</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">snapshotRenderer.ts</code></td>
      <td>Render received snapshots</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rtdbRelay.ts</code></td>
      <td>Relay</td>
    </tr>
  </tbody>
</table>

<p>Two decisions determine bandwidth.</p>

<p><strong>Quantization substitutes for rest detection.</strong> Nothing computes whether an object has come to rest. If the quantized value matches the previous one, that object drops out of the delta automatically. In a merge puzzle most objects are stacked and still most of the time, so this alone cuts the payload substantially.</p>

<p><strong>The send path was attached without modifying the engine.</strong> <code class="language-plaintext highlighter-rouge">localSnapshotSource</code> is a read tap over object positions the engine already exposes. No send hook was cut into the engine, so the single-player path behaves identically whether networking is present or not.</p>

<h3 id="rendering-the-remote-board-exactly-like-a-bot-match">Rendering the remote board exactly like a bot match</h3>

<p>The remote opponent’s board has to render with <strong>identical</strong> effects and framing to a bot match. Any difference reads as lag or a bug.</p>

<p>That requires sharing render code, and <code class="language-plaintext highlighter-rouge">renderer.ts</code> is a do-not-modify file by policy. So the code moved into <code class="language-plaintext highlighter-rouge">fxRender.ts</code> and <code class="language-plaintext highlighter-rouge">frameRender.ts</code> <strong>without a single character of the body changing.</strong> A diff of zero was written down as a verification item, and the new file headers record why the exception was granted.</p>

<p>The rule that came from it: <strong>when sharing becomes necessary, don’t mix refactoring and behavior change in the same commit.</strong> Mixed, you can no longer tell whether a behavior difference came from the move or the change.</p>

<p>There is a cost. Editing any of those three files lands on three screens at once — single-player, bot match, remote. One fix, three surfaces to check.</p>

<h3 id="keeping-screen-size-out-of-the-outcome">Keeping screen size out of the outcome</h3>

<p>If the physics board derives from canvas pixels, <strong>a bigger screen means bigger capacity.</strong> In a merge puzzle, fitting more is simply an advantage, and in versus that’s unfairness.</p>

<p>Versus therefore pins logical dimensions (<code class="language-plaintext highlighter-rouge">360×390</code>). Physics runs on that space and the screen only scales the result. Resizing the window does nothing physical.</p>

<p>Swapping which board sits on top uses the same principle. Orientation — gravity, launch origin, basket position — is baked into the canonical coordinate space, so DOM reordering can’t reorient it, and touching the coordinate space would propagate to the opponent’s view and to settlement. The canvas output is flipped with CSS <code class="language-plaintext highlighter-rouge">scaleY(-1)</code> and only text is pre-flipped. <strong>Physics, snapshots, and networking are untouched.</strong></p>

<h3 id="lobby-and-reconnection">Lobby and reconnection</h3>

<p>Opening a room was easy. Establishing that <strong>both sides are genuinely present</strong> was not.</p>

<ul>
  <li>The lobby starts only when <strong>both ready flags and presence</strong> are satisfied at once. Reading ready alone lets a closed window’s stale ready flag start a ghost match.</li>
  <li>Re-entry is allowed <strong>only for the same <code class="language-plaintext highlighter-rouge">clientId</code></strong>, preserving the atomicity of room claiming.</li>
  <li>Refresh-recovery mid-match tees 1Hz keyframes locally and rebuilds the board through the engine’s additive API on return. This is the one explicit exception to the no-engine-modification rule.</li>
</ul>

<p>Building it broke a design assumption by measurement: <strong>RTDB push keys were assumed monotonic, but they can move backwards when the sender recalibrates its server clock offset.</strong> The fix was a monotonic gate — the last-processed key only ever moves forward, so a regressed key is treated as already handled.</p>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>To run two modes on one engine, don’t put the mode inside the engine. Put the overrides in a thin wrapper around it.</li>
  <li>Making bots and remote players share an interface enables staged shipping. In exchange, game logic must never leak below that interface.</li>
  <li>Anything that affects fairness must not be derived from the display. Pin logical dimensions and scale the presentation.</li>
  <li>Separate the commit that moves code from the commit that changes behavior. When a zero diff against the original can be a verification item, make it one.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="game-dev" />
      
        <category term="multiplayer" />
      
        <category term="architecture" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[싱글 플레이용 물리 퍼즐 엔진을 고치지 않고 1대1 실시간 대전을 붙인 구조. 보드 1장 = 참가자 1명, 봇과 원격 상대를 같은 Opponent 계약으로 교체, 스냅샷 스트림, 고정 논리 치수로 지킨 공정성.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">뭉치뭉치 - 런타임 채널 판정</title>
      <link href="https://sasostudio.dev/2026/08/28/runtime-channel-detection/" rel="alternate" type="text/html" title="뭉치뭉치 - 런타임 채널 판정" />
      <published>2026-08-28T01:30:00+09:00</published>
      <updated>2026-08-28T01:30:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/28/runtime-channel-detection</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/28/runtime-channel-detection/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>같은 게임을 세 곳에 낸다. 웹, 미니앱 플랫폼, 안드로이드 앱이다. 앱을 세 벌로 나누지 않고 <strong>하나의 코드베이스가 런타임에 자기가 어디서 돌고 있는지 판정해</strong> 동작을 바꾼다.</p>

<p>이 구조에서 나온 결함 몇 개를 정리한다. 판정 함수 한 줄이 틀리면 채널 하나가 통째로 죽는다.</p>

<h2 id="1-판정">1. 판정</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>window.__appsInToss 또는 'AIT' in window  → 'ait'        (미니앱)
isNativePlatform()                         → 'capacitor'  (안드로이드 앱)
그 외                                      → 'web'
</code></pre></div></div>

<h2 id="2-채널별로-갈리는-것">2. 채널별로 갈리는 것</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>웹</th>
      <th>미니앱</th>
      <th>안드로이드 앱</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인증</td>
      <td>없음(로컬)</td>
      <td>플랫폼 식별자 → 커스텀 토큰</td>
      <td>Google 로그인 / 익명 폴백</td>
    </tr>
    <tr>
      <td>Firestore 컬렉션</td>
      <td>—</td>
      <td>별도 컬렉션</td>
      <td>별도 컬렉션</td>
    </tr>
    <tr>
      <td>광고</td>
      <td>없음</td>
      <td>플랫폼 광고</td>
      <td>AdMob</td>
    </tr>
    <tr>
      <td>1대1 대전</td>
      <td>차단</td>
      <td>허용</td>
      <td>허용</td>
    </tr>
    <tr>
      <td>뒤로가기</td>
      <td>브라우저</td>
      <td>플랫폼 시스템 내비</td>
      <td>Android 백버튼 전용 경로</td>
    </tr>
  </tbody>
</table>

<p>게임 로직은 세 채널이 완전히 동일하다. 차이는 <strong>인증·수익화·진입 게이트 세 층에만</strong> 있다. 그래서 분기를 그 세 층에 가두고 나머지를 공유하는 쪽이 싸다. 대신 분기점마다 “왜 이 채널만”을 주석으로 남기는 규율이 필수다.</p>

<h2 id="3-windowcapacitor로-판정하면-안-된다">3. <code class="language-plaintext highlighter-rouge">!!window.Capacitor</code>로 판정하면 안 된다</h2>

<p><code class="language-plaintext highlighter-rouge">@capacitor/core</code>는 <strong>브라우저에서도 전역을 정의한다.</strong> 존재 여부만 보는 판정은 항상 참이 되고, 웹이 전부 네이티브 앱으로 오판된다.</p>

<p>2026-08-05에 이 사고를 냈다. <code class="language-plaintext highlighter-rouge">web</code> 분기가 통째로 죽어서 웹에서 스토어 안내가 안 뜨고 네이티브용 스플래시가 잘못 노출됐다. <code class="language-plaintext highlighter-rouge">isNativePlatform()</code>으로 교체해 고쳤다.</p>

<p>문제는 이게 <strong>재발</strong>이라는 점이다. 나흘 전인 8월 1일에 App Check 초기화에서 이미 같은 함정을 밟았다. 그때는 증상만 고치고 판정 방식을 안 바꿨다. 지금은 판정 함수 주석에 두 사고의 기록이 같이 들어가 있다.</p>

<p>교훈은 하나다. <strong>전역의 존재는 환경을 증명하지 않는다.</strong> 라이브러리가 자기 전역을 어느 환경에 정의하는지는 라이브러리 사정이고, 그걸 물어보라고 만든 API가 따로 있다.</p>

<h2 id="4-분기를-추가할-때-봐야-하는-세-곳">4. 분기를 추가할 때 봐야 하는 세 곳</h2>

<p>채널 조건을 하나 추가하면 <strong>표시 조건·텍스트·핸들러</strong> 셋을 동시에 고쳐야 한다.</p>

<table>
  <thead>
    <tr>
      <th>빠뜨린 것</th>
      <th>증상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>표시 조건(<code class="language-plaintext highlighter-rouge">&amp;&amp;</code> 조건절)</td>
      <td>기능이 통째로 사라진다</td>
    </tr>
    <tr>
      <td>텍스트</td>
      <td>안 되는 이유를 엉뚱하게 안내한다</td>
    </tr>
    <tr>
      <td>핸들러</td>
      <td>버튼은 멀쩡한데 눌러야 막힌다</td>
    </tr>
  </tbody>
</table>

<p>세 번째가 제일 오래 산다. 코드 리뷰에서도 화면에서도 안 보이고, 실기기에서 눌러봐야 나온다.</p>

<p>이것과 짝인 반대 사고도 있었다. <strong>코드를 안 건드렸는데 위험해진 항목</strong>이다. 로그아웃 버튼은 몇 주째 그대로였는데, 다른 화면의 노출 조건을 넓히면서 그 버튼까지 가는 경로가 열렸다. 재로그인 수단이 없는 채널에서 로그아웃하면 갇힌다. 노출 조건을 넓히는 변경은 그 화면 안에서만 끝나지 않는다.</p>

<h2 id="5-인증-4겹-장애">5. 인증 4겹 장애</h2>

<p>미니앱 채널에 정식 인증을 붙일 때, 실기기에서 진입이 안 됐다. 첫 조치는 헛발이었고, 그 뒤로는 한 겹을 벗길 때마다 그 에러가 사라지고 다음 에러가 나왔다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">순서</th>
      <th>조치</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">1</td>
      <td>인증 서비스 콘솔에 서빙 도메인 추가</td>
      <td><strong>변화 없음</strong> — 같은 에러 지속</td>
    </tr>
    <tr>
      <td style="text-align: center">2</td>
      <td>검증 옵션 조정</td>
      <td>401 소멸, 다음 에러 등장</td>
    </tr>
    <tr>
      <td style="text-align: center">3</td>
      <td>서명 IAM 권한 부여</td>
      <td>500 소멸, 다음 에러 등장</td>
    </tr>
    <tr>
      <td style="text-align: center">4</td>
      <td><strong>API 키의 리퍼러 허용 목록에 서빙 도메인 추가</strong></td>
      <td>해결</td>
    </tr>
  </tbody>
</table>

<p>1번과 4번은 둘 다 “도메인을 등록한다”인데 등록하는 곳이 다르다. 1번은 인증 서비스 콘솔이고 4번은 API 키 자체의 제한 설정이다. 1번을 하고 “도메인은 처리했다”고 넘긴 것이 조사를 길게 만들었다.</p>

<p>4번이 근본 원인이었고, 증상이 비대칭이라 더 어려웠다. <strong>Firebase browser key의 리퍼러 제한은 Auth와 App Check에는 강제되고 Firestore·RTDB에는 강제되지 않는다.</strong> 그래서 “DB는 멀쩡히 읽고 쓰는데 로그인만 403”이라는 그림이 나온다. 인증 코드를 아무리 봐도 안 나오는 종류의 결함이다.</p>

<p>이 트랙 전체는 App Check 토큰 발급이 계속 실패하던 상태에서 시작했다. 원인이 3건이었다.</p>

<ol>
  <li>네이티브 초기화가 아예 빠져 있었다</li>
  <li>채널 오판정 분기(§3)를 타서 잘못된 provider로 갔다</li>
  <li>도메인이 등록돼 있지 않았다</li>
</ol>

<p>셋 다 각각으로는 사소하고, 겹쳐 있으니 “발급이 안 된다”는 증상 하나로만 보였다.</p>

<h2 id="6-빌드-파이프라인도-채널마다-다르다">6. 빌드 파이프라인도 채널마다 다르다</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>웹        npm run build        → dist/
미니앱    npm run build:ait    → .ait 번들
안드로이드 npm run build → cap sync android → bundleRelease
</code></pre></div></div>

<p>SDK 메이저 전환 때 공식 마이그레이션 도구를 돌렸더니, 그 도구가 <strong><code class="language-plaintext highlighter-rouge">package.json</code>의 <code class="language-plaintext highlighter-rouge">build</code> 스크립트를 말없이 덮어썼다.</strong> 웹 배포 설정이 <code class="language-plaintext highlighter-rouge">npm run build</code>를 쓰고 있었으므로 그대로 뒀으면 웹 배포가 통째로 깨진다.</p>

<p>잡을 수 있었던 건 착수 전에 배포 설정을 읽어두고 “이 도구가 이 줄을 건드릴 것”이라고 계획서에 적어뒀기 때문이다. 자동 변환 도구를 돌리기 전에 <strong>그 도구가 쓸 수 있는 파일을 먼저 목록화</strong>하는 게 값싼 방어다.</p>

<h2 id="정리">정리</h2>

<ul>
  <li>채널 판정은 한 곳에 있어야 하고, 그 한 곳은 전역 존재 검사가 아니라 런타임이 제공하는 질의 API를 써야 한다.</li>
  <li>채널 분기를 추가하면 표시 조건·텍스트·핸들러 세 곳을 전수 확인한다. 하나만 빠져도 증상이 다르다.</li>
  <li>증상이 비대칭이면 코드가 아니라 그 위 계층(키 제한·도메인·권한)을 의심한다. 같은 자격증명이 서비스마다 다르게 강제될 수 있다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>The same game ships to three places: web, a mini-app platform, and an Android app. Rather than maintaining three builds, <strong>one codebase determines at runtime where it is running</strong> and adjusts behavior.</p>

<p>What follows is a set of defects that came out of that structure. When the detection function is wrong by one expression, an entire channel dies.</p>

<h3 id="detection">Detection</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>window.__appsInToss or 'AIT' in window  → 'ait'        (mini-app)
isNativePlatform()                       → 'capacitor'  (Android app)
otherwise                                → 'web'
</code></pre></div></div>

<h3 id="what-actually-differs">What actually differs</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Web</th>
      <th>Mini-app</th>
      <th>Android app</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Auth</td>
      <td>none (local)</td>
      <td>platform identifier → custom token</td>
      <td>Google sign-in / anonymous fallback</td>
    </tr>
    <tr>
      <td>Firestore collection</td>
      <td>—</td>
      <td>separate</td>
      <td>separate</td>
    </tr>
    <tr>
      <td>Ads</td>
      <td>none</td>
      <td>platform ads</td>
      <td>AdMob</td>
    </tr>
    <tr>
      <td>1v1 versus</td>
      <td>blocked</td>
      <td>allowed</td>
      <td>allowed</td>
    </tr>
    <tr>
      <td>Back navigation</td>
      <td>browser</td>
      <td>platform system nav</td>
      <td>Android back button path</td>
    </tr>
  </tbody>
</table>

<p>Game logic is identical across all three. The differences live in exactly three layers — auth, monetization, entry gating — so confining branches to those layers and sharing everything else is the cheaper structure. The cost is a discipline: every branch point carries a comment explaining why this channel only.</p>

<h3 id="dont-detect-with-windowcapacitor">Don’t detect with <code class="language-plaintext highlighter-rouge">!!window.Capacitor</code></h3>

<p><code class="language-plaintext highlighter-rouge">@capacitor/core</code> <strong>defines its global in browsers too.</strong> A presence check is therefore always true, and web gets classified as native every time.</p>

<p>It was found and fixed on 2026-08-05. The entire <code class="language-plaintext highlighter-rouge">web</code> branch had died: store guidance never rendered on the web build, and a native-only splash appeared where it shouldn’t. The fix was <code class="language-plaintext highlighter-rouge">isNativePlatform()</code>.</p>

<p>The uncomfortable part is that it was a <strong>repeat.</strong> Four days earlier, App Check initialization hit the same trap. That time the symptom got fixed and the detection style didn’t. The detection function now carries both incidents in its header comment.</p>

<p>One rule came out of it: <strong>the presence of a global does not prove an environment.</strong> Which environments a library defines its global in is the library’s business, and there is usually a dedicated API for asking the actual question.</p>

<h3 id="three-places-to-change-per-branch">Three places to change per branch</h3>

<p>Adding one channel condition means changing <strong>the visibility condition, the copy, and the handler</strong> together.</p>

<table>
  <thead>
    <tr>
      <th>Missed</th>
      <th>Symptom</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Visibility condition (the <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> clause)</td>
      <td>The feature disappears entirely</td>
    </tr>
    <tr>
      <td>Copy</td>
      <td>It explains the wrong reason for being unavailable</td>
    </tr>
    <tr>
      <td>Handler</td>
      <td>The button looks fine and fails on tap</td>
    </tr>
  </tbody>
</table>

<p>The third survives longest. It’s invisible in review and invisible on screen; it only shows up when someone taps it on a device.</p>

<p>There’s a mirror-image failure worth pairing with it: <strong>an item that became dangerous without its code being touched.</strong> A sign-out button sat unchanged for weeks, then a separate change widened the visibility condition of another screen and opened a path to it. On a channel with no way to sign back in, signing out strands the user. Widening a visibility condition doesn’t stay inside that screen.</p>

<h3 id="a-four-layer-auth-failure">A four-layer auth failure</h3>

<p>When proper authentication went onto the mini-app channel, entry failed on real devices. The first action was a false start; after that, each layer peeled by <strong>fixing one thing, watching that error disappear, and getting the next error.</strong></p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Step</th>
      <th>Action</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">1</td>
      <td>Add the serving domain in the auth service console</td>
      <td><strong>No change</strong> — same error</td>
    </tr>
    <tr>
      <td style="text-align: center">2</td>
      <td>Adjust the verification option</td>
      <td>401 gone, next error appears</td>
    </tr>
    <tr>
      <td style="text-align: center">3</td>
      <td>Grant signing IAM permission</td>
      <td>500 gone, next error appears</td>
    </tr>
    <tr>
      <td style="text-align: center">4</td>
      <td><strong>Add the serving domain to the API key’s HTTP referrer allowlist</strong></td>
      <td>Resolved</td>
    </tr>
  </tbody>
</table>

<p>Steps 1 and 4 are both “register the domain,” but in different places — one is the auth service console, the other is the restriction config on the API key itself. Doing step 1 and mentally checking off “domain handled” is what stretched the investigation.</p>

<p>Step 4 was the root cause, and the asymmetry made it harder. <strong>A Firebase browser key’s referrer restriction is enforced for Auth and App Check but not for Firestore or RTDB.</strong> The resulting picture is “the database reads and writes fine, only sign-in returns 403” — a defect no amount of staring at auth code will surface.</p>

<p>The whole track started from App Check token issuance failing continuously. There were three causes:</p>

<ol>
  <li>Native initialization was missing entirely</li>
  <li>The channel misdetection above routed it to the wrong provider</li>
  <li>The domain was not registered</li>
</ol>

<p>Each is minor alone. Stacked, they present as a single symptom: nothing is issued.</p>

<h3 id="build-pipelines-differ-per-channel-too">Build pipelines differ per channel too</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>web        npm run build        → dist/
mini-app   npm run build:ait    → .ait bundle
Android    npm run build → cap sync android → bundleRelease
</code></pre></div></div>

<p>During a major SDK migration, the official migration tool <strong>silently overwrote the <code class="language-plaintext highlighter-rouge">build</code> script in <code class="language-plaintext highlighter-rouge">package.json</code>.</strong> Web deployment was configured to run <code class="language-plaintext highlighter-rouge">npm run build</code>, so leaving it would have broken web deploys entirely.</p>

<p>It got caught because the deploy config was read before starting and the plan explicitly noted “this tool will touch this line.” Listing the files an automated migration tool is allowed to write, before running it, is a cheap defense.</p>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>Channel detection belongs in one place, and that place should query a runtime API rather than test for the presence of a global.</li>
  <li>Adding a channel branch means auditing three sites: visibility condition, copy, handler. Each omission produces a different symptom.</li>
  <li>When a symptom is asymmetric, suspect the layer above the code — key restrictions, domains, permissions. The same credential can be enforced differently per service.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="architecture" />
      
        <category term="mobile" />
      
        <category term="firebase" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[하나의 코드베이스가 웹·미니앱·안드로이드 중 어디서 도는지 런타임에 판정하는 구조와, 거기서 나온 결함들. 전역 존재 검사의 함정, 분기를 추가할 때 봐야 할 세 곳, 인증 4겹 장애.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">뭉치뭉치 - 캔버스 GPU 가속 상실</title>
      <link href="https://sasostudio.dev/2026/08/28/canvas-gpu-acceleration-loss/" rel="alternate" type="text/html" title="뭉치뭉치 - 캔버스 GPU 가속 상실" />
      <published>2026-08-28T01:00:00+09:00</published>
      <updated>2026-08-28T01:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/28/canvas-gpu-acceleration-loss</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/28/canvas-gpu-acceleration-loss/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>물리 합체 퍼즐 게임을 Canvas 2D 한 장으로 그린다. WebGL은 쓰지 않는다. 이 게임에 며칠 동안 닫히지 않은 성능 결함이 하나 있었다. <strong>전면 광고를 본 뒤 게임으로 돌아오면 화면 전체가 슬로우모션이 된다.</strong> 실기기에서만 재현되고, 앱을 껐다 켜면 사라진다.</p>

<p>원인은 광고가 아니었다. 캔버스 크기 재할당이었다.</p>

<h2 id="1-확정-모델">1. 확정 모델</h2>

<p>실측으로 확정한 동작은 이렇다.</p>

<blockquote>
  <p>2D 캔버스는 <strong>생성·리사이즈 직후엔 비가속</strong>이다. DOM에 붙은 채 계속 그려지며 프레임을 넘겨야 GPU 가속으로 승격된다. <strong>전면 웹뷰(광고)를 본 뒤에는 이 승격이 일어나지 않는다.</strong> 이미 승격된 캔버스는 광고를 겪어도 유지된다.</p>
</blockquote>

<p>비가속이 되면 픽셀당 비용이 3~4배가 된다. 게임 로직이 느려지는 게 아니라 fill-rate 병목이라 화면 전체가 균일하게 느려진다.</p>

<h2 id="2-실측치">2. 실측치</h2>

<p>같은 실행 안에서 만든 비가속 눈금 대비 ns/px다.</p>

<table>
  <thead>
    <tr>
      <th>관측 대상</th>
      <th>값</th>
      <th>해석</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>광고 0회, 갓 만든 캔버스</td>
      <td><code class="language-plaintext highlighter-rouge">newNow</code> 1.26 (눈금 1.27)</td>
      <td>새 캔버스는 평시에도 비가속</td>
    </tr>
    <tr>
      <td>광고 후 계속 써온 캔버스</td>
      <td><code class="language-plaintext highlighter-rouge">single</code> 0.19 (눈금 2.40)</td>
      <td>승격된 건 광고를 견딘다</td>
    </tr>
    <tr>
      <td><strong>같은 엘리먼트를 리사이즈</strong></td>
      <td>0.14 → 0.97</td>
      <td><strong>리사이즈가 승격을 푼다</strong></td>
    </tr>
    <tr>
      <td>부팅 때 만들어 상시 그린 2장</td>
      <td><code class="language-plaintext highlighter-rouge">preAdA+B</code> 0.65 (눈금 2.40)</td>
      <td>2장 동시에도 가속 유지</td>
    </tr>
    <tr>
      <td>굽고 방치한 캔버스</td>
      <td><code class="language-plaintext highlighter-rouge">preAdCold</code> 1.08 vs <code class="language-plaintext highlighter-rouge">preAdA</code> 0.12</td>
      <td>회수된다</td>
    </tr>
    <tr>
      <td>DOM 미부착</td>
      <td>0.45~1.42</td>
      <td>일관성 없음 — 붙여둬야 한다</td>
    </tr>
  </tbody>
</table>

<p>세 번째 행이 결론이다. <code class="language-plaintext highlighter-rouge">canvas.width</code>에 값을 대입하는 순간 백킹스토어가 재할당되고, <strong>대입한 값이 이전과 같아도 재할당은 일어난다.</strong></p>

<h2 id="3-수정">3. 수정</h2>

<table>
  <thead>
    <tr>
      <th>위치</th>
      <th>조치</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sharedCanvas.ts</code></td>
      <td>캔버스를 앱 수명 동안 <strong>키별로 재사용</strong>(<code class="language-plaintext highlighter-rouge">single</code> / <code class="language-plaintext highlighter-rouge">versusMine</code> / <code class="language-plaintext highlighter-rouge">versusOpp</code>)</td>
      <td>화면마다 새로 만들면 광고 후 비가속</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sharedCanvas.ts</code> <code class="language-plaintext highlighter-rouge">bootVersusCanvases()</code></td>
      <td>부팅 시 대전 보드 2장을 <strong>최종 크기로</strong> 생성 → 3px 숨은 컨테이너에 부착 → 매 프레임 소량 드로잉</td>
      <td>광고보다 먼저 승격시키고, 회수되지 않게 유지</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">renderer.ts</code> <code class="language-plaintext highlighter-rouge">initCanvas</code></td>
      <td>백킹 크기가 같으면 <strong>재할당하지 않는다</strong></td>
      <td>§2 세 번째 행</td>
    </tr>
    <tr>
      <td>같은 곳</td>
      <td><code class="language-plaintext highlighter-rouge">ctx.scale(dpr)</code> → <code class="language-plaintext highlighter-rouge">ctx.setTransform(dpr, …)</code></td>
      <td>재할당을 건너뛰면 변환이 초기화되지 않아 scale이 누적된다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">VersusGame.tsx</code></td>
      <td>보드 캔버스를 React가 만들지 않고 <strong>프리웜 엘리먼트를 옮겨 꽂는다</strong></td>
      <td>위 전부를 성립시키는 마지막 조각</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fxRender.ts</code> <code class="language-plaintext highlighter-rouge">getRenderDpr()</code></td>
      <td>DPR 상한 2</td>
      <td>픽셀 수 = 비용. 상한이 없으면 dpr 3 기기에서 2.25배</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">offscreenCache.ts</code></td>
      <td>단일 슬롯 → <strong>키별</strong> 캐시</td>
      <td>두 보드가 교대로 다른 키를 요청해 매 프레임 재생성되던 회귀</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">setTransform</code> 항목은 수정의 부작용이다. 재할당을 건너뛰면 컨텍스트 상태가 살아남으므로, 매 프레임 <code class="language-plaintext highlighter-rouge">scale</code>을 곱하던 코드가 그대로면 배율이 누적된다.</p>

<h2 id="4-기각된-해법">4. 기각된 해법</h2>

<p>전부 실측으로 기각했다. 재시도 금지 목록으로 문서에 남겼다.</p>

<table>
  <thead>
    <tr>
      <th>해법</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>백킹스토어 재초기화 / 배경 숨김 / 캐시 비우기</td>
      <td>무반응</td>
    </tr>
    <tr>
      <td>프리웜 v1(미부착·1픽셀) · v2(2px)</td>
      <td>효과 없음</td>
    </tr>
    <tr>
      <td>프리웜 v3(화면 크기·30프레임)</td>
      <td><strong>1회 성공 후 재현 실패</strong> — 굽고 방치해 회수됐다</td>
    </tr>
    <tr>
      <td>페이지 리로드</td>
      <td>무효. 전부 재생성되어 싱글까지 느려짐</td>
    </tr>
    <tr>
      <td>공유 캔버스에 <strong>새 키</strong> 추가</td>
      <td>새 키가 대전 첫 진입 때 태어난다 → 광고 뒤면 여전히 비가속</td>
    </tr>
    <tr>
      <td>싱글 캔버스를 대전 보드에 꽂기</td>
      <td>크기가 달라 재할당된다 (ON 1.19 = OFF 1.16)</td>
    </tr>
    <tr>
      <td>렌더 DPR 낮추기</td>
      <td>효과는 있으나 화질 저하로 채택 불가</td>
    </tr>
  </tbody>
</table>

<p>v3은 검증 번들까지 만들었다가 철회했다. 유저에게 나가지는 않았다. <strong>1회 통과를 해결로 판단한 게 원인이다.</strong> 재현 시험을 붙이기 전에는 성공으로 취급하지 않는 규칙을 이때 넣었다.</p>

<h2 id="5-계측이-네-번-틀렸다">5. 계측이 네 번 틀렸다</h2>

<p>이게 더 옮겨 쓸 만한 부분이다. 원인 규명보다 <strong>재는 방법을 만드는 데</strong> 시간이 더 들었다.</p>

<table>
  <thead>
    <tr>
      <th>시도</th>
      <th>왜 0이 나오나</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fillRect</code> 전후 <code class="language-plaintext highlighter-rouge">performance.now()</code></td>
      <td>래스터가 JS 호출 밖에서 일어난다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rAF</code> 프레임 간격</td>
      <td>프레임이 늘어나려면 합성기 역압이 필요하다. 화면에 안 뜬 캔버스는 래스터가 생략되고, 헤드리스엔 vsync 역압조차 없다 — 부하를 512배까지 올려도 0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getImageData(0,0,1,1)</code>로 강제 flush</td>
      <td><strong>그 1픽셀에 필요한 만큼만 다시 그린다.</strong> 전면 fill 24회가 1픽셀 fill 24회로 줄어든다</td>
    </tr>
    <tr>
      <td>사본에 복사 후 전면 읽기(절대값)</td>
      <td>GPU→CPU 회수 비용이 가속 쪽에만 붙어 <strong>신호가 뒤집힌다</strong> (GPU 0.88 vs SW 0.20)</td>
    </tr>
    <tr>
      <td>✅ <strong>기울기 <code class="language-plaintext highlighter-rouge">t(4N) − t(N)</code></strong></td>
      <td>고정비용이 상쇄된다. 검증값 GPU 0.01~0.02 vs SW 0.11~0.13</td>
    </tr>
  </tbody>
</table>

<p>여기에 두 가지를 덧붙였다.</p>

<ul>
  <li><strong>비가속 눈금 자가 생성</strong> — 매 실행 <code class="language-plaintext highlighter-rouge">willReadFrequently: true</code> 캔버스를 만들어 “이 기기에서 비가속이면 얼마”를 재고, 모든 판정을 그 대비로 낸다. 기기·해상도가 달라도 절대값 선입견에 기대지 않는다.</li>
  <li><strong>부하 자동 보정</strong> — 데스크톱은 폰보다 100배 빠르다. 고정 부하로는 양쪽에서 못 쓴다. 눈금 캔버스로 프레임이 목표치를 넘을 때까지 부하를 2배씩 올린다.</li>
</ul>

<h2 id="6-계측을-프로덕션에-넣는-방법">6. 계측을 프로덕션에 넣는 방법</h2>

<p>이 버그는 실기기에서만 재현된다. 즉 <strong>프로덕션 번들에서 수치를 읽을 수 있어야</strong> 조사가 가능하다.</p>

<p>처음엔 <code class="language-plaintext highlighter-rouge">import.meta.env.DEV</code> 가드를 썼는데, 프로덕션 빌드에서 계측이 통째로 빠져 실기기 수치가 0이었다. Vite <code class="language-plaintext highlighter-rouge">define</code> 빌드 상수로 바꿨다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$env:PERF="1"; npm run build:ait   # 계측 번들
npm run build                       # 출시 번들
</code></pre></div></div>

<p>기본값 false에서 계측 코드가 전부 트리셰이킹되므로 출시 산출물이 불변이고, <strong>출시 전에 grep으로 계측 문자열 0건을 기계 확인할 수 있다.</strong> 가드를 고르는 기준은 “개발 중에 켜지는가”가 아니라 “필요할 때 프로덕션에서 켤 수 있는가”였다.</p>

<h2 id="7-남은-제약">7. 남은 제약</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">initCanvas</code>는 싱글·대전·재초기화 공용이다. 여기 손대면 재할당이 되살아난다.</li>
  <li>대전 캔버스 크기를 바꾸는 변경(레이아웃·DPR·논리 치수)은 프리웜 크기와 정확히 맞아야 한다. 어긋나면 재할당이 일어나 같은 증상이 돌아온다.</li>
  <li>배경 에셋을 늘린 뒤에는 가속을 재측정해야 한다. 메모리 압박으로 캔버스가 회수되면 §2 다섯 번째 행 상태가 된다.</li>
</ul>

<h2 id="정리">정리</h2>

<ul>
  <li>성능을 게임 프레임으로 추론하면 안 된다. 엔진 2개·물리·합성이 뒤섞여 신호가 나와도 원인이 분리되지 않는다. 대상 하나만 재는 전용 하네스가 필요하다.</li>
  <li>대역(surrogate)으로 재면 “대역은 이런데 진짜 그 화면은?”이 끝내 안 갈린다. 실물을 등록해 직접 잰다.</li>
  <li>기각한 해법은 기각 이유와 함께 남긴다. 없으면 다음 사람이 다시 시도한다. 이번엔 그 다음 사람이 나였다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>The game is a physics merge puzzle rendered on a single Canvas 2D surface — no WebGL. One performance defect stayed open for days: <strong>coming back from a fullscreen ad left the whole screen in slow motion.</strong> It only reproduced on real devices and disappeared after an app restart.</p>

<p>The ad wasn’t the cause. Canvas backing-store reallocation was.</p>

<h3 id="the-model">The model</h3>

<blockquote>
  <p>A 2D canvas is <strong>unaccelerated right after creation or resize.</strong> It gets promoted to GPU compositing only after staying attached to the DOM and being drawn across frames. <strong>After a fullscreen WebView (an ad), that promotion no longer happens.</strong> A canvas already promoted survives the ad.</p>
</blockquote>

<p>Unaccelerated costs 3–4× per pixel. Nothing in the game loop slows down; it’s a fill-rate bottleneck, so the entire screen degrades uniformly.</p>

<h3 id="measurements">Measurements</h3>

<p>ns/px, relative to an unaccelerated reference generated in the same run.</p>

<table>
  <thead>
    <tr>
      <th>Subject</th>
      <th>Value</th>
      <th>Reading</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Zero ads, freshly created canvas</td>
      <td><code class="language-plaintext highlighter-rouge">newNow</code> 1.26 (ref 1.27)</td>
      <td>New canvases are unaccelerated even without ads</td>
    </tr>
    <tr>
      <td>Canvas in continuous use across an ad</td>
      <td><code class="language-plaintext highlighter-rouge">single</code> 0.19 (ref 2.40)</td>
      <td>Promotion survives the ad</td>
    </tr>
    <tr>
      <td><strong>Same element, resized</strong></td>
      <td>0.14 → 0.97</td>
      <td><strong>Resize revokes promotion</strong></td>
    </tr>
    <tr>
      <td>Two canvases warmed since boot</td>
      <td><code class="language-plaintext highlighter-rouge">preAdA+B</code> 0.65 (ref 2.40)</td>
      <td>Two stay accelerated simultaneously</td>
    </tr>
    <tr>
      <td>Warmed then left idle</td>
      <td><code class="language-plaintext highlighter-rouge">preAdCold</code> 1.08 vs <code class="language-plaintext highlighter-rouge">preAdA</code> 0.12</td>
      <td>It gets reclaimed</td>
    </tr>
    <tr>
      <td>Detached from DOM</td>
      <td>0.45–1.42</td>
      <td>Inconsistent — keep it attached</td>
    </tr>
  </tbody>
</table>

<p>Row three is the finding. Assigning to <code class="language-plaintext highlighter-rouge">canvas.width</code> reallocates the backing store, <strong>and it reallocates even when the assigned value is identical to the current one.</strong></p>

<h3 id="the-fix">The fix</h3>

<table>
  <thead>
    <tr>
      <th>Where</th>
      <th>What</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sharedCanvas.ts</code></td>
      <td>Reuse canvases <strong>by key</strong> for the app’s lifetime (<code class="language-plaintext highlighter-rouge">single</code> / <code class="language-plaintext highlighter-rouge">versusMine</code> / <code class="language-plaintext highlighter-rouge">versusOpp</code>)</td>
      <td>Creating one per screen means unaccelerated after an ad</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bootVersusCanvases()</code></td>
      <td>At boot, create both versus boards <strong>at final size</strong>, attach to a 3px hidden container, draw a little every frame</td>
      <td>Promote before any ad, and keep it from being reclaimed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">renderer.ts</code> <code class="language-plaintext highlighter-rouge">initCanvas</code></td>
      <td>Skip reallocation when backing size is unchanged</td>
      <td>Row three above</td>
    </tr>
    <tr>
      <td>same</td>
      <td><code class="language-plaintext highlighter-rouge">ctx.scale(dpr)</code> → <code class="language-plaintext highlighter-rouge">ctx.setTransform(dpr, …)</code></td>
      <td>Skipping reallocation preserves context state, so a per-frame <code class="language-plaintext highlighter-rouge">scale</code> would compound</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">VersusGame.tsx</code></td>
      <td>React doesn’t create the board canvas; the pre-warmed element is moved in</td>
      <td>The piece that makes the rest work</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getRenderDpr()</code></td>
      <td>Cap DPR at 2</td>
      <td>Pixels are the cost; uncapped, a dpr-3 device pays 2.25×</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">offscreenCache.ts</code></td>
      <td>Single slot → <strong>keyed</strong> cache</td>
      <td>Two boards alternating keys regenerated the cache every frame</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">setTransform</code> line is a consequence of the fix, not an independent change. Once reallocation is skipped, context state survives, and the old per-frame <code class="language-plaintext highlighter-rouge">scale</code> call starts stacking.</p>

<h3 id="rejected-approaches">Rejected approaches</h3>

<p>All rejected by measurement, and recorded as a do-not-retry list.</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>Outcome</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Backing-store reinit / hiding backgrounds / clearing caches</td>
      <td>No effect</td>
    </tr>
    <tr>
      <td>Pre-warm v1 (detached, 1px) and v2 (2px)</td>
      <td>No effect</td>
    </tr>
    <tr>
      <td>Pre-warm v3 (full size, 30 frames)</td>
      <td><strong>Passed once, then failed to reproduce</strong> — warmed and left idle, so it was reclaimed</td>
    </tr>
    <tr>
      <td>Page reload</td>
      <td>Useless; everything is recreated, so single-player slows down too</td>
    </tr>
    <tr>
      <td>Adding a <strong>new key</strong> to the shared canvas</td>
      <td>The new key is born on first versus entry, so post-ad it’s still unaccelerated</td>
    </tr>
    <tr>
      <td>Reusing the single-player canvas for a versus board</td>
      <td>Different size → reallocation (ON 1.19 = OFF 1.16)</td>
    </tr>
    <tr>
      <td>Lowering render DPR</td>
      <td>Works, but rejected on image quality</td>
    </tr>
  </tbody>
</table>

<p>v3 got as far as a verification bundle before being pulled; it never reached users. The root cause was <strong>treating one passing run as a fix.</strong> The rule that came out of it: nothing counts as solved until it has a reproduction test.</p>

<h3 id="four-wrong-ways-to-measure-this">Four wrong ways to measure this</h3>

<p>This is the transferable part. Building a way to measure took longer than finding the cause.</p>

<table>
  <thead>
    <tr>
      <th>Attempt</th>
      <th>Why it reads zero</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">performance.now()</code> around <code class="language-plaintext highlighter-rouge">fillRect</code></td>
      <td>Rasterization happens outside the JS call</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rAF</code> frame intervals</td>
      <td>Frame time only grows under compositor back-pressure. An offscreen canvas skips rasterization entirely, and headless has no vsync back-pressure at all — zero even at 512× load</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getImageData(0,0,1,1)</code> to force a flush</td>
      <td><strong>It redraws only what that one pixel needs.</strong> 24 full-surface fills collapse into 24 single-pixel fills</td>
    </tr>
    <tr>
      <td>Copy to a scratch canvas and read it whole (absolute)</td>
      <td>GPU→CPU readback cost lands only on the accelerated side, so <strong>the signal inverts</strong> (GPU 0.88 vs SW 0.20)</td>
    </tr>
    <tr>
      <td>✅ <strong>Slope, <code class="language-plaintext highlighter-rouge">t(4N) − t(N)</code></strong></td>
      <td>Fixed costs cancel. Validation: GPU 0.01–0.02 vs SW 0.11–0.13</td>
    </tr>
  </tbody>
</table>

<p>Two supporting pieces:</p>

<ul>
  <li><strong>Self-generated unaccelerated reference.</strong> Every run creates a <code class="language-plaintext highlighter-rouge">willReadFrequently: true</code> canvas to answer “what does unaccelerated cost <em>on this device</em>,” and every verdict is expressed relative to that. No absolute thresholds baked in.</li>
  <li><strong>Auto-calibrated load.</strong> A desktop is ~100× faster than a phone, so a fixed workload is useless on one end or the other. The reference canvas doubles the load until frame time clears a target.</li>
</ul>

<h3 id="instrumentation-that-survives-a-production-build">Instrumentation that survives a production build</h3>

<p>The bug reproduced only on real devices, which means investigation required reading numbers <strong>from a production bundle.</strong></p>

<p>The first attempt used an <code class="language-plaintext highlighter-rouge">import.meta.env.DEV</code> guard. Production builds dropped the instrumentation entirely, so on-device readings were zero. It moved to a Vite <code class="language-plaintext highlighter-rouge">define</code> build constant instead.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$env:PERF="1"; npm run build:ait   # instrumented bundle
npm run build                       # release bundle
</code></pre></div></div>

<p>Defaulting to false lets the whole instrumentation path tree-shake out, so release artifacts are unchanged and <strong>a pre-release grep can mechanically confirm zero instrumentation strings.</strong> The criterion for choosing a guard wasn’t “is it on during development” but “can I turn it on in production when I need it.”</p>

<h3 id="constraints-that-remain">Constraints that remain</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">initCanvas</code> is shared by single-player, versus, and reinit paths. Touching it can bring reallocation back.</li>
  <li>Any change to versus canvas size — layout, DPR, logical dimensions — must match the pre-warm size exactly. A mismatch reallocates and the symptom returns.</li>
  <li>Adding background assets requires re-measuring acceleration. Under memory pressure the canvas gets reclaimed, which lands on row five of the table.</li>
</ul>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>Don’t infer rendering performance from game frame rate. Two engines, physics, and compositing are mixed in; even a real signal doesn’t isolate a cause. Build a harness that measures one subject.</li>
  <li>Measuring a surrogate never closes the question “the surrogate reads this, but what about the actual surface?” Register the real thing and measure it directly.</li>
  <li>Record rejected approaches with the reason they were rejected. Without that, the next person retries them. Here the next person was me.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="performance" />
      
        <category term="canvas" />
      
        <category term="mobile" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[전면 광고를 본 뒤 게임이 느려지던 결함의 원인은 광고가 아니라 캔버스 크기 재할당이었다. 같은 값을 대입해도 GPU 가속이 풀리는 동작을 실측으로 확정하고, 계측이 네 번 틀린 과정까지 남겼다.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">밤샘팟 - 되돌릴 수 없는 것들</title>
      <link href="https://sasostudio.dev/2026/08/27/what-you-cant-undo/" rel="alternate" type="text/html" title="밤샘팟 - 되돌릴 수 없는 것들" />
      <published>2026-08-27T12:00:00+09:00</published>
      <updated>2026-08-27T12:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/27/what-you-cant-undo</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/27/what-you-cant-undo/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>앞의 두 글(<a href="/2026/08/27/mesh-deformation-from-scratch/">캐릭터</a>, <a href="/2026/08/27/thirteen-bundles-in-one-day/">실기기</a>)이 코드 이야기였다면 이번은 절차 이야기다.</p>

<p>미니앱 배포에는 되돌릴 수 없는 동작이 여러 개 있다. 한 번 무르면 다시 못 올리는 제출이 있고, 한 번 올리면 이전 버전으로 못 내려오는 런타임이 있고, 앞 단계가 끝나야 뒤 단계를 시작할 수 있는 순차 관문이 있다.</p>

<p>비가역은 코드 문제가 아니라 순서와 절차의 문제다. 앱 하나를 출시하고 세 번 업데이트하면서 그 성질에 맞춰 만든 습관을 적는다.</p>

<h2 id="제출-앞에서-한-번-멈추기">제출 앞에서 한 번 멈추기</h2>

<p>출시 첫 주에 번들 하나를 영구히 잃었다. 릴리즈 노트에 오타가 있어서 검수를 취소했는데, 취소한 번들은 재요청이 안 된다. 코드는 멀쩡했고 문안 한 줄 때문이었다.</p>

<p>그 뒤로 릴리즈 노트를 제출 전에 기계로 검사한다. 맞춤법 검사기가 없어도 범위를 좁힐 수는 있다. 문장 어미·금지 표현·구두점 패턴을 훑고, 지난 승인본에 없던 낱말만 뽑아서 눈으로 본다. 이전에 통과한 문장은 다시 볼 필요가 없다.</p>

<p>그리고 초안을 그대로 밀지 않고 승인을 한 번 받는다. 되돌릴 수 없는 제출 앞에서는 한 번 멈추는 쪽이 싸다.</p>

<h2 id="승인난-번들을-버린-판단">승인난 번들을 버린 판단</h2>

<p>출시 전날, 검수 승인이 난 번들을 손에 쥔 상태에서 그걸 버리고 다시 받았다.</p>

<p>그 승인본에는 캐릭터 눈망울이 투명하게 뚫린 자산이 들어 있었다. 내가 만든 배경 제거 도구가 흰 배경을 밝기로만 판정하다가 흰 눈망울까지 지운 것이다. 42장 중 31장, 총 5.8만 픽셀이 뚫려 있었다. 캐릭터가 주인공인 앱이라 첫인상에 직결됐다.</p>

<p>재검수가 하루도 안 걸려 일정 손해는 없었다. 승인본을 쥐고 있다는 이유로 타협했으면 되돌릴 기회가 없는 첫인상을 버릴 뻔했다.</p>

<p>운이 좋았던 건 그 도구가 알파만 0으로 만들고 색은 원본을 남겨뒀다는 점이다. 알파만 되돌리니 색을 지어내지 않고 무손실로 복원됐다. 도구 자체도 판정 기준을 “밝기가 높은가”에서 “바깥과 이어져 있는가”로 바꿔 재발을 막았다.</p>

<h2 id="첫-삽은-미리보기로">첫 삽은 미리보기로</h2>

<p>출시하고 여드레 뒤, SDK를 강제로 올려야 하는 마감이 생겼다. 새 버전으로 출시하면 되돌릴 수 없다고 공지에 명시돼 있었다.</p>

<p>설계서에 적어둔 순서가 첫 명령에서 틀렸다는 게 드러났다. 자동 변환 도구는 새 버전을 먼저 설치해야 존재하는 물건이었다. 구버전 도구에만 있던 미리보기 옵션을 먼저 돌린 덕에 파일이 지워지기 전에 알았다. 되돌릴 수 없는 작업일수록 첫 삽을 미리보기로 뜨는 게 값지다.</p>

<p>도구가 해준 일도 항목별로 검사했다. 실제로 두 가지가 나왔다.</p>

<table>
  <thead>
    <tr>
      <th>도구가 한 일</th>
      <th>왜 위험한가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>개발용 목(mock) 도구를 빌드 설정에 조건 없이 삽입</td>
      <td>실제 SDK를 가짜로 바꿔치기하는 물건이라, 프로덕션 번들에 남으면 안 된다</td>
    </tr>
    <tr>
      <td>빌드 명령을 설정 파일에서 가져다 덮어씀</td>
      <td>우리가 붙여둔 타입 검사 단계가 조용히 사라진다</td>
    </tr>
  </tbody>
</table>

<p>앞엣것은 개발 서버일 때만 끼우도록 못 박고, 빌드 산출물을 문자열로 뒤져 흔적이 0인 것을 확인했다.</p>

<p>공지문만 읽었으면 못 봤을 급소도 하나 있었다. 앱이 “지금 토스 앱 안인가”를 판정하려고 들여다보던 내부 전역이 새 버전에는 아예 없다. 전역 이름만이 아니라 그 안을 조회하는 키 이름까지 바뀌어 있었다. 한쪽만 고쳤으면 로컬과 테스트는 전부 초록인데 실기기에서만 안전영역·뒤로가기·사용자 키가 통째로 죽었을 것이다. 패키지를 직접 열어 문자열을 세어 본 덕에 나왔다.</p>

<h2 id="되돌리기를-미리-싸게-만들어-두기">되돌리기를 미리 싸게 만들어 두기</h2>

<p>표정 자산의 해상도를 개선하는 작업을 하다가 관리자 판정으로 전부 롤백했다. 고친 결과를 폰에서 실제로 보이는 크기로 놓고 보니 차이가 거의 안 보였기 때문이다.</p>

<p>롤백이 쉬웠던 건 운이 아니었다. 착수할 때 원본 자산을 덮어쓰지 않고 새 이름으로 굽기로 정해뒀다. 그래서 되돌리기가 생성물 복원과 새 파일 격리로 끝났다. 복원 뒤 도구를 다시 돌려 자동 생성 파일이 백업과 바이트 단위로 같은지까지 확인했다. 덮어쓰기를 택했다면 같은 판정이 훨씬 비쌌을 것이다.</p>

<p>배운 건 그 다음이다. 이 개선을 제기한 건 관리자가 아니라 나였는데, 인계 문서에는 “관리자 지시”라고 적어뒀다. 그런 지시는 없었고, 같은 문서 아래쪽에는 처음부터 “출시가 급한 지금은 그대로 가도 된다”가 적혀 있었다. 한 문서 안에서 두 서술이 충돌했고 나는 위쪽만 보고 착수했다.</p>

<h2 id="문서가-거짓말할-때">문서가 거짓말할 때</h2>

<p>가장 아찔했던 건 문서를 믿었다가 마감 앞에서 알 뻔한 일이다.</p>

<p>출시 전 체크리스트를 대조하다가, 콘솔에 우리 앱이 뭐라고 등록돼 있는지 확인하려고 직접 조회했다. 인계 문서는 “임시저장, 아이콘 주입 완료”라고 적어뒀는데 실제로는 아이콘·카테고리·설명·스크린샷이 전부 비어 있었다. 더 중요한 건 파이프라인 상태값이었다. 번들 검수 요청이 “앱 정보 첫 승인 전”으로 막혀 있었다. 검토를 두 번 순차로 받아야 하는데 우리는 첫 번째를 시작조차 안 한 상태였다.</p>

<p>문서 말대로 믿었으면 번들부터 만들어 놓고, 올릴 수 없다는 걸 마감 앞에서 알았을 것이다. 인계 문서는 그때의 계획이지 지금의 사실이 아니다. 외부 시스템의 상태는 그 시스템에 물어야 한다.</p>

<p>같은 종류를 세 번 더 겪었다.</p>

<ul>
  <li>팔 문제를 풀 때, 몇 시간 전 내가 “조각내 붙이는 방식은 폐기”라고 적어둔 문장이 정답을 막고 있었다. 교훈을 뭉뚱그려 적으면 조건이 다른 경우까지 같이 막힌다.</li>
  <li>화면 상태가 서버 응답을 기다려 굳는 경합을 고쳤는데, 열하루 뒤 같은 파일에서 같은 구조로 재발했다. 그때 남긴 경고가 바로 옆 줄에 주석으로 붙어 있었다. 옆 줄에 있는 경고는 읽히지 않는다.</li>
  <li>코드 주석에 “이 검사가 있다”고 써놓고 검사를 실제로는 만들지 않은 것도 뒤늦게 잡았다. 주석이 거짓이 되는 순간 다음 사람이 없는 안전망을 믿는다.</li>
</ul>

<h2 id="배운-것">배운 것</h2>

<ul>
  <li>되돌릴 수 없는 동작 앞에는 반드시 한 단계를 끼운다. 미리보기, 기계 검사, 승인 중 하나.</li>
  <li>되돌리기 비용은 착수할 때 정해진다. 덮어쓰지 않고 새 이름으로 만들어 두면 롤백이 파일 이동으로 끝난다.</li>
  <li>문서와 외부 시스템이 어긋나면 언제나 시스템이 맞다. 조회할 수단이 있는데 문서만 읽는 건 게으른 게 아니라 위험하다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>The <a href="/2026/08/27/mesh-deformation-from-scratch/">first</a> and <a href="/2026/08/27/thirteen-bundles-in-one-day/">second</a> posts in this set were about code. This one is about process.</p>

<p>Mini-app distribution has several one-way doors: submissions that can’t be sent again once withdrawn, a runtime you can’t roll back once shipped, and sequential gates where the second can’t start until the first clears.</p>

<p>Irreversibility isn’t a code problem, it’s an ordering problem. Here are the habits that came out of shipping one app and updating it three times against that constraint.</p>

<h3 id="stop-once-before-an-irreversible-submit">Stop once before an irreversible submit</h3>

<p>We permanently lost a bundle in the first week. A typo in the release notes led to cancelling the review, and a cancelled bundle can’t be resubmitted. The code was fine. It was one line of copy.</p>

<p>Release notes now get a machine pass before submission. There’s no spellchecker available, but the scope can still be narrowed: sweep sentence endings, forbidden phrasings, punctuation patterns — then extract only the words that weren’t in the last approved version and read those by eye. Sentences that already passed don’t need reading again.</p>

<p>And the draft doesn’t go straight through; it gets one approval. In front of a door that only opens one way, stopping once is cheap.</p>

<h3 id="throwing-away-an-approved-bundle">Throwing away an approved bundle</h3>

<p>The night before launch, holding a bundle that had already passed review, I threw it out and rebuilt.</p>

<p>That approved bundle contained assets where the character’s eye highlights were punched transparent. A background-removal tool I’d written judged white purely by brightness and deleted the white of the eyes along with the backdrop — 31 of 42 sprites, about 58,000 pixels. In an app where the character <em>is</em> the product, that lands directly on first impression.</p>

<p>Re-review took under a day, so there was no schedule cost. Compromising because an approved build was already in hand would have spent a first impression that doesn’t come back.</p>

<p>The lucky part: the tool zeroed alpha but left the original color channels intact. Restoring alpha alone recovered the sprites losslessly, with no invented color. The tool’s own criterion changed too — from “is this pixel bright” to “is this region connected to the outside” — so it can’t recur.</p>

<h3 id="take-the-first-swing-in-preview-mode">Take the first swing in preview mode</h3>

<p>A week after launch, a forced SDK upgrade landed with a deadline, and the announcement was explicit that shipping on the new major version is not reversible.</p>

<p>The order I’d written in the design doc turned out to be wrong on the very first command: the migration tool only exists <em>after</em> the new version is installed. A preview flag that only existed on the old tool caught it before any files were rewritten. The more irreversible the work, the more a dry run is worth.</p>

<p>I also checked what the tool did, item by item. Two things came out.</p>

<table>
  <thead>
    <tr>
      <th>What the tool did</th>
      <th>Why it’s dangerous</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Injected a dev mock plugin into the build config unconditionally</td>
      <td>It substitutes a fake for the real SDK, so it must not survive into the production bundle</td>
    </tr>
    <tr>
      <td>Overwrote the build command from its own config template</td>
      <td>The typecheck step we’d added silently disappears</td>
    </tr>
  </tbody>
</table>

<p>The first was gated to dev-server only, and I grepped the build output to confirm zero traces.</p>

<p>One more thing was invisible from the announcement alone. The app detects “am I running inside the host app” by inspecting an internal global — and that global doesn’t exist in the new version. Not just its name: the key looked up inside it had changed too. Fixing one and not the other would have left local and CI entirely green while safe-area handling, back navigation, and the user key all died on real devices only. That turned up by unpacking the package and counting strings.</p>

<h3 id="make-undo-cheap-before-you-need-it">Make undo cheap before you need it</h3>

<p>A round of work improving the resolution of the facial expression sprites was rolled back in full. Viewed at the size they actually occupy on a phone, the difference was barely visible.</p>

<p>The rollback being easy wasn’t luck. At the start I’d decided not to overwrite source assets and to bake to new filenames instead. So undoing meant restoring generated files and quarantining thirteen new ones. After restoring, I re-ran the tool and confirmed the regenerated files were byte-identical to the backup. Had I chosen overwrite, the same call would have cost far more.</p>

<p>The real lesson came after. <em>I</em> raised that improvement, not the person reviewing — but the handoff doc recorded it as an instruction from them. There was no such instruction, and further down the same document sat the original line: “with launch this close, leaving it as-is is fine.” Two statements in one document contradicted each other, and I acted on the one nearer the top.</p>

<h3 id="when-the-document-lies">When the document lies</h3>

<p>The closest call was believing a document.</p>

<p>While reconciling the pre-launch checklist, I queried the console directly to see how the app was registered. The handoff doc said “saved as draft, icon injected.” In reality the icon, category, description, and screenshots were all empty. Worse was the pipeline state: bundle review was blocked pending first approval of the app info. Two sequential reviews are required, and we hadn’t started the first one.</p>

<p>Believing the document would have meant building the bundle first and discovering it couldn’t be submitted right at the deadline. <strong>A handoff document is the plan as of then, not the state as of now.</strong> For external systems, ask the system.</p>

<p>Three more of the same species:</p>

<ul>
  <li>While working the arm problem, a line I’d written hours earlier — <em>cutting into parts is rejected</em> — was hiding the solution. A lesson written too broadly blocks the cases where the conditions differ.</li>
  <li>I fixed a race where screen state froze while waiting on a server response. Eleven days later the same structure reappeared in the same file, with my own warning sitting on the adjacent line as a comment. A warning on the adjacent line does not get read.</li>
  <li>A comment claimed a check existed that had never been written. The moment a comment becomes false, the next person trusts a safety net that isn’t there.</li>
</ul>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>Put one step in front of every one-way door: a dry run, a machine check, or an approval.</li>
  <li>The cost of undo is decided when you start. Bake to new names instead of overwriting and rollback becomes a file move.</li>
  <li>When a document and an external system disagree, the system is right. Reading only the document when you have a way to query is not lazy, it’s dangerous.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="release" />
      
        <category term="process" />
      
        <category term="mobile" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[미니앱 배포의 되돌릴 수 없는 동작 앞에서 만든 습관. 제출 전 기계 검사와 승인, 승인난 번들을 버린 판단, 미리보기로 시작한 SDK 전환, 문서보다 실제 시스템을 믿는 법.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">밤샘팟 - 실기기 13차</title>
      <link href="https://sasostudio.dev/2026/08/27/thirteen-bundles-in-one-day/" rel="alternate" type="text/html" title="밤샘팟 - 실기기 13차" />
      <published>2026-08-27T11:30:00+09:00</published>
      <updated>2026-08-27T11:30:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/27/thirteen-bundles-in-one-day</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/27/thirteen-bundles-in-one-day/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p><a href="/2026/08/27/mesh-deformation-from-scratch/">지난 글</a>에서 캐릭터를 코드로 움직인 이야기를 썼다. 이번은 그 캐릭터가 실기기에서 깨진 이야기다.</p>

<p>이 프로젝트에는 브라우저 자동화 도구가 없다. 테스트는 node 환경이라 레이아웃을 못 본다. 검사 961건이 통과해도 화면이 어떻게 생겼는지에 대해서는 아무 말도 하지 않는다. 그래서 판정을 숫자로 하는 도구를 여러 개 만들었고, 그 도구들이 먼저 틀렸다.</p>

<h2 id="계측이-먼저-틀렸다">계측이 먼저 틀렸다</h2>

<p>리깅에 사흘을 쓰기 전에 “이 방식이 폰에서 버티나”를 확인하려고 실기기 성능 게이트를 앞에 뒀다. 관리자가 열어보니 실패가 떴다. 확인해 보니 세 가지가 겹친 계측 결함이었고 대상은 멀쩡했다.</p>

<table>
  <thead>
    <tr>
      <th>증상</th>
      <th>실제 원인</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>로딩 시간이 13초 → 27초 → 33초로 계속 늘어남</td>
      <td>재측정할 때 시작 시각을 다시 안 잡아서, 버튼 누른 순간까지 흐른 시간이 통째로 로딩으로 찍힘</td>
    </tr>
    <tr>
      <td>해상도 버튼을 눌러도 아무 일이 없음</td>
      <td>기기 배율과 비교해 더 작은 값을 쓰게 돼 있어 배율 100% 모니터에서는 무효</td>
    </tr>
    <tr>
      <td>최저 성능이 실제 179인데 1로 집계</td>
      <td>첫 그림 직후 리소스를 올리느라 튀는 몇 프레임이 최저값으로 잡힘</td>
    </tr>
  </tbody>
</table>

<p>지표 자체도 바꿨다. 초당 프레임 수는 주사율에 막혀서(폰 60, 데스크톱 180) 여유가 얼마나 남았는지를 못 보여준다. 한 프레임에 실제로 쓴 작업 시간으로 바꾸니 60Hz 예산 16.7ms 중 0.7ms를 쓴다는 값이 나왔고, 그게 “폰이 훨씬 느려도 들어온다”는 판단의 근거가 됐다. 로딩도 부팅·자산·첫 그림 세 구간으로 쪼갰다. 4.27초 중 3.54초가 개발 서버 몫이었다. 안 쪼갰으면 엉뚱한 데를 최적화했을 것이다.</p>

<h2 id="평균이-못-보는-것">평균이 못 보는 것</h2>

<p>배경 그림은 요즘 폰 비율에 비해 세로가 모자라서, 맨 아래 띠를 잘라 반복해 채운다. 이음매가 보이면 안 되니 “띠 위아래 색 차이”를 검사로 걸어뒀고, 새 배경이 3.3으로 여유롭게 통과했다(기준 10).</p>

<p>미리보기를 열어 보니 화면 오른쪽에 작은 자국이 세로로 줄지어 찍혀 있었다. 띠 맨 위 세 줄에 책 더미 아래끝이 걸쳐 들어왔고, 그게 화면 아래로 반복된 것이었다. 기존 검사가 못 잡은 이유가 핵심이다. 그 검사는 열 평균을 보는데, 가로로 좁고 어두운 자국은 48줄 평균에 묻혀 편차가 4.2밖에 안 된다. 평균은 “작은 것이 여러 번 반복된다”를 못 본다. 픽셀 단위 이물 탐지로 바꾸니 같은 띠에서 39개가 잡혔다.</p>

<p>같은 계열의 실수가 색에도 있었다. 이유 8종에 색을 줬는데 헷갈린다는 지적을 받았다. 기존 검사는 “색이 서로 다르다”만 봐서 값이 1만 달라도 통과한다. 화면에서는 같은 색인데 테스트가 안심시키고 있었던 것이다. 사람 눈 가중치를 준 거리로 다시 재니 최소 67.6이었고, 색상환에서 50도씩 벌려 재배치해 131.6이 됐다.</p>

<p>수치가 통과했다고 끝난 게 아니라, 통과한 수치가 무엇을 안 보는지를 물어야 했다.</p>

<h2 id="하루에-번들-13차">하루에 번들 13차</h2>

<p>출시 직전 하루는 통째로 실기기 대응이었다. 그날 번들을 열세 번 다시 만들었다. 하단 잘림, 상단 여백, 내비바, 팝업 투명, 색 구분, 발열.</p>

<p>가장 뼈아픈 건 화면 하단이 잘리는 문제였다. 처음엔 “방 배경이 폭에 비례해 세로를 다 먹는다”로 진단하고 배경 크기를 줄이는 수정에 들어갔다. 관리자가 멈춰 세우고 한마디를 보탰다. “같은 기기에서 다른 앱 두 개는 멀쩡했어.”</p>

<p>그 한마디가 진단을 뒤집었다. 같은 기기에서 다른 앱이 멀쩡하면 기기 문제가 아니다. 대조하니 차이는 루트 레이아웃 한 줄이었다. 다른 앱은 최소 높이라 넘치면 페이지가 길어지고, 이 앱만 높이를 못 박고 넘치는 것을 잘라내고 있었다.</p>

<p>그 한 줄이 전날 내가 넣은 수정이었다. “카드를 펼치면 목록이 화면 밖으로 잘려 나간다”를 고치려고 높이를 고정했고, 그게 다른 조건에서 하단을 먹는 새 문제를 만들었다. 당시 설계 기준 폰에서 여유가 7px이었는데 통과했으니 괜찮다고 봤다. 여유 7px은 통과가 아니라 경고였다.</p>

<h2 id="141px">141px</h2>

<p>같은 날 오후, 세 번째 지적(“책상 앞면이 안 보인다”)에 대해서는 못 고친다는 결론을 계산표와 함께 냈다. 화면 세로 731 중 방 배경이 610을 먹고 하단 UI가 238을 필요로 하니 141px이 물리적으로 모자라다고.</p>

<p>틀렸다. 관리자가 화면을 보고 한마디로 뒤집었다. “아니 지금 공간이 남잖아.”</p>

<p>진짜 원인은 쪽지 줄의 flex 설정이었다. 남는 높이를 위아래 줄이 균등 분배받게 해뒀더니, 쪽지 두 장짜리 줄이 자기 내용보다 훨씬 큰 높이를 빈 여백으로 차지하고 있었다. 공간이 없던 게 아니라 낭비되고 있었다.</p>

<p>오판의 구조가 뼈아프다. 내 계산은 화면 높이도 요소 높이도 전부 추정값이었는데, 그걸 “물리적으로 불가능”이라는 단정으로 포장했다. 추정 하나가 틀리자 결론이 통째로 뒤집혔다. 화면을 볼 수 없는 쪽이 예산 계산으로 불가능을 선언하면 안 된다. 실기기를 보는 사람의 관찰이 언제나 더 강하다.</p>

<p>폐기된 계산표는 지우지 않고 문서에 남기면서 “다음 세션도 이걸 근거로 배경 축소를 꺼내지 말 것”이라고 못을 박았다.</p>

<h2 id="폰이-뜨겁다">폰이 뜨겁다</h2>

<p>발열 지적을 받고 렌더 루프를 열었다. 매 프레임 캔버스의 <code class="language-plaintext highlighter-rouge">clientWidth</code>/<code class="language-plaintext highlighter-rouge">clientHeight</code>를 읽고 있었다. 이 속성을 읽으면 브라우저가 그 자리에서 레이아웃을 강제로 계산한다. 초당 60번 레이아웃이 돈 셈이다. 더 허탈한 건 이 앱에 이미 크기를 추적하는 옵저버가 있었다는 점이다. 루프가 그걸 안 쓰고 매번 DOM에 물어봤다.</p>

<p>두 번째는 매 프레임 배열 할당이었다. 드로우 함수가 호출마다 배열을 복사·정렬해 새로 만드는데, 그 함수가 한 프레임에 대여섯 번 불린다. 초당 300개가 넘는 임시 배열이 GC를 계속 깨웠다. 버퍼 재사용으로 바꿨다.</p>

<p>여기서 일부러 한 번에 다 바꾸지 않았다. 발열의 본질은 픽셀 처리량(초당 1.4억 픽셀)이고 그걸 줄이는 수단은 프레임 제한이나 해상도 축소인데, 둘 다 체감이 바뀐다. 무해한 것만 먼저 넣고 효과를 재기로 했다. 한꺼번에 손대면 무엇이 효과였는지 알 수 없다.</p>

<h2 id="배운-것">배운 것</h2>

<ul>
  <li>판정 도구를 먼저 검증하지 않으면 판정이 뒤집힌다. 성능 게이트에서 한 번, 반복 타일에서 한 번, 색 구분에서 한 번 겪었다.</li>
  <li>통과한 수치가 무엇을 안 보는지 물어야 한다. 평균은 반복되는 작은 것을 못 보고, “다르다”는 “얼마나 다른가”를 못 본다.</li>
  <li>검증할 수 없는 것을 통과로 포장하지 않는다. 레이아웃 수정 하나는 “고쳤다”가 아니라 “논리적 근거뿐이고 실기기 확인이 남았다”로 보고했다. 도구가 없어 못 본 것을 못 봤다고 말하는 게 그 상황에서 할 수 있는 최선이다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p><a href="/2026/08/27/mesh-deformation-from-scratch/">The previous post</a> covered animating the character in code. This one is about that character breaking on real devices.</p>

<p>There’s no browser automation in this project. Tests run in node, so they can’t see layout. 961 passing checks say nothing about what the screen looks like. So I built several tools that judge things numerically — and those tools were wrong first.</p>

<h3 id="the-measurement-was-wrong-before-the-thing-being-measured">The measurement was wrong before the thing being measured</h3>

<p>Before spending three days on rigging, I put a real-device performance gate in front of it: does this approach survive on a phone at all? It came back FAILED. Three separate instrumentation defects, stacked. The subject was fine.</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Actual cause</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Load time climbing 13s → 27s → 33s</td>
      <td>The start timestamp wasn’t reset between runs, so all the idle time before the button press counted as loading</td>
    </tr>
    <tr>
      <td>Resolution buttons did nothing</td>
      <td>The control took the smaller of requested and device scale, so on a 100%-scale monitor every button was a no-op</td>
    </tr>
    <tr>
      <td>Worst-case frame reported as 1 when it was really 179</td>
      <td>The few spiky frames right after first paint, while resources upload, were sampled as the minimum</td>
    </tr>
  </tbody>
</table>

<p>I changed the metric too. Frames per second is capped by refresh rate (60 on the phone, 180 on the desktop), so it can’t show how much headroom is left. Switching to actual work time per frame gave 0.7ms against a 16.7ms budget at 60Hz — and <em>that</em> is what justified “a much slower phone still fits.” I split load time into boot, assets, and first paint as well: 3.54 of the 4.27 seconds belonged to the dev server. Without the split I’d have optimized the wrong thing.</p>

<h3 id="what-an-average-cant-see">What an average can’t see</h3>

<p>The room background is too short for modern phone aspect ratios, so the bottom strip is cut and tiled downward. Seams would be obvious, so there’s a check on the color difference between the strip’s top and bottom edges. The new background passed comfortably at 3.3 (threshold 10).</p>

<p>Then I opened the preview and found small marks repeating in a vertical column down the right side. The bottom edge of a book stack intruded into the top three rows of the strip, and that got tiled all the way down. Why the existing check missed it is the interesting part: it compares column <em>averages</em>, and a narrow dark mark disappears into a 48-row average — deviation 4.2. An average cannot see “something small, repeated many times.” Switching to per-pixel outlier detection found 39 in the same strip.</p>

<p>The same class of mistake showed up in color. Eight reasons, eight colors, and the report was that they were hard to tell apart. The existing test only asserted the colors <em>differed</em>, so a difference of 1 passed. Identical on screen; reassuring in CI. Re-measuring with perceptually weighted distance gave a minimum of 67.6; redistributing them 50° apart on the wheel brought it to 131.6.</p>

<p>Passing a threshold isn’t the end. The question is what the passing number isn’t looking at.</p>

<h3 id="thirteen-bundles-in-one-day">Thirteen bundles in one day</h3>

<p>The day before launch was entirely real-device work. Thirteen rebuilds: bottom clipping, top inset, nav bar, transparent popup, color separation, thermals.</p>

<p>The worst was the bottom of the screen getting cut off. My first diagnosis was that the room background scales with width and eats the full height, and I started shrinking the background. Then: “the other two apps on the same device were fine.”</p>

<p>That flipped the diagnosis. If other apps are fine on the same device, it isn’t the device. The difference turned out to be one line in the root layout — the other apps used a minimum height so overflow lengthens the page, while this one pinned the height and clipped the overflow.</p>

<p>That line was my own fix from the day before. I’d pinned the height to stop an expanded card’s list from spilling off screen, and it created a new failure under different conditions. On the reference phone at the time the margin was 7px, and I called that a pass. 7px of margin isn’t a pass, it’s a warning.</p>

<h3 id="141px-1">141px</h3>

<p>That afternoon, for a third complaint (“the front of the desk isn’t visible”), I filed a “can’t be fixed” with a budget table: of 731 vertical pixels, the room background takes 610 and the bottom UI needs 238, so we’re 141px short — physically impossible.</p>

<p>Wrong. One look at the actual screen: “there’s clearly space right now.”</p>

<p>The real cause was the flex configuration on the sticky-note row. Leftover height was distributed evenly between rows, so a row holding two small notes claimed far more height than its contents and sat there as empty space. The space wasn’t missing. It was being wasted.</p>

<p>The shape of the error is what stings. Every number in my calculation — screen height, element heights — was an estimate, and I packaged the result as a categorical impossibility. One bad estimate and the whole conclusion inverted. <strong>Someone who can’t see the screen shouldn’t declare impossibility from a budget table.</strong> The observation from the person holding the device always outranks it.</p>

<p>I kept the discredited table in the doc rather than deleting it, with a note: don’t reopen “shrink the background” on the strength of this.</p>

<h3 id="the-phone-is-hot">The phone is hot</h3>

<p>The render loop was reading the canvas’s <code class="language-plaintext highlighter-rouge">clientWidth</code>/<code class="language-plaintext highlighter-rouge">clientHeight</code> every frame. Reading those properties forces the browser to compute layout on the spot — sixty forced layouts per second. The deflating part: the app already had a resize observer tracking exactly that. The loop just didn’t use it and asked the DOM instead.</p>

<p>Second waste: per-frame allocation. The draw function copied and sorted an array on every call, and it’s called five or six times per frame. Over 300 temporary arrays per second, keeping GC awake. Switched to a reused buffer.</p>

<p>I deliberately did <em>not</em> change everything at once. The real driver of the heat is pixel throughput (about 140 million pixels/sec), and the levers for that are frame limiting or resolution reduction — both of which change how the app feels. Land the harmless ones first, then measure. Change everything together and you learn nothing about which change mattered.</p>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>Validate the judging tool before trusting the judgment. Three times: the performance gate, the tiling check, the color check.</li>
  <li>Ask what a passing number ignores. Averages hide small repeated defects; “different” hides “how different.”</li>
  <li>Don’t dress up unverifiable work as verified. One layout fix was reported as “the only support for this is reasoning; real-device confirmation is still outstanding,” not as “fixed.” Saying you couldn’t see it is the best available move when you have no tool that can.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="mobile" />
      
        <category term="testing" />
      
        <category term="performance" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[밤샘팟 출시 직전 하루 동안 번들을 13번 다시 만든 실기기 대응 기록. 틀린 성능 계측, 평균이 못 보는 반복 자국, 추정값으로 내린 「불가능」 판정, 발열을 만든 렌더 루프를 다룬다.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">밤샘팟 - 그림에 없는 관절</title>
      <link href="https://sasostudio.dev/2026/08/27/mesh-deformation-from-scratch/" rel="alternate" type="text/html" title="밤샘팟 - 그림에 없는 관절" />
      <published>2026-08-27T11:00:00+09:00</published>
      <updated>2026-08-27T11:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/27/mesh-deformation-from-scratch</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/27/mesh-deformation-from-scratch/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>앱인토스 8월 챌린지에 미니앱을 하나 냈다. 밤에 안 자는 사람이 열면 지금 같이 깨어 있는 사람 수와 그 이유 순위를 보여주는 앱이고, 화면 한가운데 베개 캐릭터가 서 있다. 캐릭터가 주인공이라 “이걸 어떻게 움직이나”가 첫 관문이었다.</p>

<h2 id="리깅-도구를-버렸다">리깅 도구를 버렸다</h2>

<p>처음엔 업계에서 쓰는 2D 리깅 도구를 채택했다. 웹 연동 사전조사까지 마치고 나서야 문제가 드러났다. 그 도구는 사람이 마우스로 정점을 찍고 변형을 잡는 물건이다. 계획서에는 “도구로 리깅한다”고 적혀 있었는데, 그 일을 할 사람이 비어 있었다.</p>

<p>그래서 원리만 직접 구현했다. 그림을 격자로 나누고 정점 위치마다 다른 각도를 주면 관절이 꺾이지 않고 활처럼 휜다. 변형 수학을 화면에서 떼어내 순수 함수로 만들고 검사부터 붙였더니 그 자리에서 결함이 하나 나왔다. 휘는 기준선을 조각 가장자리에 뒀더니 길이가 12% 줄어들었다. 기준선을 중앙으로 옮기니 보존됐다.</p>

<p>성능은 덤이었다. 안 쓰는 기능이 없으니 원래 도구보다 30~50배 빨랐다.</p>

<h2 id="35벌을-7--5로">35벌을 7 + 5로</h2>

<p>캐릭터는 사용자가 고른 이유대로 행동한다. 당시 이유가 7종이고(나중에 8종이 됐다) 표정이 5종이니, 조합을 그림으로 만들면 35벌이다. 그렇게 갈 뻔했다.</p>

<p>축을 나눠서 막았다. 포즈와 소품은 이유가 정하고, 표정은 시각이 정한다. 두 축이 독립이면 자산은 7 + 5로 끝난다. 이미지 생성에 하루 횟수 제한이 걸려 있어서 이 판단이 그대로 일정이 됐다.</p>

<p>덤으로 표현 하나가 딸려 왔다. 동작이 한 바퀴 도는 동안 앞쪽은 이유 표정, 뒤쪽은 시각 표정이 나오고 그 비율이 곧 그 시간대의 활기다. 초저녁엔 내내 이유 표정이지만 새벽 세 시엔 대부분 감긴 눈이 이긴다. 공부하다 자꾸 눈이 감기는 상태가 숫자 하나로 표현된다.</p>

<h2 id="팔을-여섯-번-고쳤다">팔을 여섯 번 고쳤다</h2>

<p>하루를 통째로 팔에 썼다.</p>

<table>
  <thead>
    <tr>
      <th>차수</th>
      <th>무엇을 고쳤나</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>팔 영역을 사각형이 아니라 그림에서 잰 경계로</td>
      <td>몸통이 옆으로 늘어나던 것 해결. 여전히 어색</td>
    </tr>
    <tr>
      <td>2</td>
      <td>회전축을 소매 뿌리에서 몸통 안쪽으로</td>
      <td>소매가 통째로 들림. 여전히 어색</td>
    </tr>
    <tr>
      <td>3</td>
      <td>위치를 섞던 것을 각도를 섞는 것으로</td>
      <td>팔 길이 보존. 여전히 어색</td>
    </tr>
    <tr>
      <td>4</td>
      <td>변형 순서를 부위 먼저·전신 나중으로</td>
      <td>팔 뿌리 35px 어긋남 해결. 여전히 어색</td>
    </tr>
    <tr>
      <td>5</td>
      <td>팔을 그림에서 떼어 몸통 뒤로</td>
      <td>이음매가 사라짐. 대신 뻣뻣해짐</td>
    </tr>
    <tr>
      <td>6</td>
      <td>떼어낸 조각에 다시 격자를 줌</td>
      <td>뻣뻣함 해소</td>
    </tr>
  </tbody>
</table>

<p>앞의 네 번은 전부 틀린 층위를 고친 것이었다. 매번 뭔가 나아졌고 매번 관리자는 여전히 어색하다고 했다.</p>

<p>다섯 번째에 인정했다. 원화가 팔 내린 자세로 그려져 있어서, 팔을 올리면 옷 어깨 모양이 그림에 없다. 없는 정보는 어떤 계산으로도 만들 수 없다. 버그가 아니라 방식의 한계였다. 관리자가 “너 솔직히 못 고치지?”라고 물었을 때 그렇다고 답한 게 전환점이었다. 그때서야 “어떻게 더 잘 휠까”에서 “휘지 말자”로 질문이 바뀌었다.</p>

<p>팔을 그림에서 떼어 몸통 뒤에 깔면 뿌리가 가려져 이음매가 존재할 수 없다. 회전 상한이 세 배가 됐다.</p>

<p>여기에 반전이 있다. 낱개 팔 자산은 이미 있었다. 뿌리가 둥근 캡으로, 애초에 가려서 붙이라고 그려진 모양이었다. 몇 시간 전에 내가 “조각내 붙이는 방식은 연결부가 드러나서 폐기”라고 문서에 적어둔 것이 이 선택지를 막고 있었다. 그때 실패한 건 캐릭터 전체를 조각내 같은 평면에 나란히 붙인 것이고, 지금은 팔만 떼어 뒤로 숨긴 것이다. 같은 단어로 묶어둔 바람에 다른 방법이 안 보였다.</p>

<p>여섯 번째는 그 대가였다. 떼어낸 팔은 네 점짜리 판때기라 통째로만 돌아서 목각인형처럼 보였다. 분리와 휨을 둘 중 하나를 고르는 문제로 본 게 잘못이었다. 분리는 자유도를 주고 휨은 자연스러움을 준다. 떼어낸 조각에 다시 격자를 줬다.</p>

<p>여섯 번으로 끝나지도 않았다. 몇 시간 뒤 소품을 붙이자 몸통 뒤에 깔린 팔을 소품이 덮어서, 아무도 물건을 쥐고 있지 않은 그림이 됐다. 팔을 가장 앞으로 옮겼다. 감추려던 어깨 이음매가 드러나지만 뿌리가 둥근 캡이라 옷의 솔기처럼 읽힌다. 최종본은 그 상태다.</p>

<h2 id="스프링이-먹어버린-것">스프링이 먹어버린 것</h2>

<p>움직임은 사인파 대신 스프링으로 굴린다. 코드는 가야 할 자세만 정하고 사이는 물리가 메운다. 계수를 강성·감쇠 그대로 두지 않고 감쇠비로 잡은 게 핵심이다. 값 하나로 “지나치느냐”가 정해지니 감으로 만지지 않고 시험으로 확인할 수 있다. 팔은 37% 지나치고 몸통은 거의 안 지나친다. 이 대비가 살아 있는 느낌을 만든다.</p>

<p>대가도 있었다. 손을 바쁘게 움직이려고 잔진동을 넣었는데 화면에 아무것도 안 나왔다. 팔 스프링의 고유진동수가 초당 한 번꼴인데 초당 예닐곱 번 떠는 신호를 넣었으니 남을 리가 없다. 스프링이 다 먹은 것이다. 물리를 넣어 자연스러움을 얻은 대가로 빠른 표현을 잃은 셈이라, 잔진동만 스프링을 건너뛰어 팔 각도에 직접 더하는 길을 따로 냈다.</p>

<h2 id="배운-것">배운 것</h2>

<ul>
  <li>변형 수학을 화면에서 떼어내면 눈으로 못 잡는 결함이 검사로 잡힌다. 길이 12% 손실도, 60fps와 30fps에서 궤적이 1.6% 갈리던 것도 그렇게 나왔다.</li>
  <li>“구조적 한계”라고 결론 내리기 전에 한 번 더 재라. 팔 영역을 눈대중 사각형으로 잡던 동안은 회전 상한을 낮춰 증상만 눌렀는데, 경계를 그림에서 실제로 재고 나니 그 제약의 원인 자체가 없어졌다.</li>
  <li>교훈을 뭉뚱그려 적으면 다음 사람이 정답을 못 본다. 이번엔 그 다음 사람이 몇 시간 뒤의 나였다.</li>
</ul>

<hr />

<h2 id="english-version">English version</h2>

<p>I shipped a mini-app for an August build challenge on the Toss platform. You open it at 2 a.m., it tells you how many other people are awake right now and why, and a pillow character stands in the middle of the screen. The character is the whole app, so the first question was how to animate it.</p>

<h3 id="dropping-the-rigging-tool">Dropping the rigging tool</h3>

<p>I’d picked a standard 2D rigging tool and finished the research on wiring it into the web before the real problem surfaced: a human sits down with a mouse and places the deformation by hand. The plan said “rig it with the tool.” It never said who.</p>

<p>So I implemented the underlying idea instead. Split the sprite into a grid, apply a different angle per vertex, and the joint bends like a bow rather than creasing. I pulled the deformation math out of the render path into pure functions and wrote the tests first — which immediately surfaced a defect. With the bend axis on the edge of the piece, the arm lost 12% of its length. Moving the axis to the center preserved it.</p>

<p>The performance was a bonus: 30–50x faster than the tool, because none of the features I wasn’t using were there.</p>

<h3 id="35-combinations-down-to-7--5">35 combinations down to 7 + 5</h3>

<p>The character acts out whichever reason you picked. Seven reasons at the time — eight in the shipped version — and five expressions. Drawn as combinations, that’s 35 sprite sets.</p>

<p>Splitting the axes killed it. Pose and props come from the reason; expression comes from the clock. Independent axes mean 7 + 5 assets instead of 7 × 5. Image generation had a daily quota, so this decision <em>was</em> the schedule.</p>

<p>It bought an expression I hadn’t planned for. Across one animation cycle the first half shows the reason’s expression and the second half shows the hour’s, and the ratio between them is how awake that hour is. At 8 p.m. it’s all reason. At 3 a.m. the closing eyes win most of the cycle. “Studying, but my eyes keep shutting” turns out to be a single number.</p>

<h3 id="six-passes-at-one-arm">Six passes at one arm</h3>

<p>An entire day.</p>

<table>
  <thead>
    <tr>
      <th>Pass</th>
      <th>What I changed</th>
      <th>Outcome</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Arm region: bounding box → boundary measured from the sprite</td>
      <td>Torso stopped stretching sideways. Still wrong</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Pivot: sleeve root → inside the torso</td>
      <td>Whole sleeve lifts now. Still wrong</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Blend positions → blend angles</td>
      <td>Arm length preserved. Still wrong</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Reorder: limb deform first, whole-body deform second</td>
      <td>Fixed a 35px pivot drift. Still wrong</td>
    </tr>
    <tr>
      <td>5</td>
      <td>Cut the arm out, render it behind the torso</td>
      <td>Seam gone. Now it’s stiff</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Give the cut-out piece its own grid</td>
      <td>Stiffness resolved</td>
    </tr>
  </tbody>
</table>

<p>The first four all fixed the wrong layer. Each one improved something, and each time the answer came back: still looks off.</p>

<p>On the fifth I admitted the real constraint. The source art is drawn arms-down, so when the arm lifts, the shoulder of the shirt <em>doesn’t exist in the image</em>. No amount of math invents information that was never drawn. It wasn’t a bug, it was the limit of the approach. The turning point was being asked, point blank, “you can’t actually fix this, can you?” — and saying no. Only then did the question change from “how do I bend this better” to “what if I don’t bend it.”</p>

<p>Cut the arm out, render it behind the torso, and the root is occluded — there is no seam to hide. Rotation range tripled.</p>

<p>The twist: the separate arm asset already existed, with a rounded cap at the root, drawn from the start to be tucked under something. What blocked me was a note I had written a few hours earlier: <em>cutting the character into parts is rejected, the joins show</em>. That failure was slicing the whole character into coplanar pieces. This was cutting one limb and hiding it behind. Filing both under the same word made the second one invisible to me.</p>

<p>The sixth pass was the bill for the fifth. A cut-out arm is a four-vertex quad, so it only rotates as a rigid slab — a wooden puppet. Treating separation and bending as an either/or was the mistake. Separation buys range; bending buys naturalness. The cut-out piece got its own grid.</p>

<p>Six wasn’t the end either. Hours later, once props went in, the prop covered an arm that was sitting behind the torso — nobody appeared to be holding anything. The arm moved to the very front. The shoulder join I’d been hiding is now visible, but the rounded cap at the root reads as a sleeve seam. That’s what shipped.</p>

<h3 id="what-the-spring-ate">What the spring ate</h3>

<p>Motion runs on springs rather than hand-authored sine waves. The code only sets a target pose; physics fills in the space between. The key decision was parameterizing by damping ratio instead of raw stiffness and damping — one number decides whether it overshoots, which makes it testable instead of a feel thing. The arm overshoots 37%, the torso barely at all, and that contrast is most of what reads as “alive.”</p>

<p>It came with a cost. I added a fast jitter so the hands would look busy, and nothing showed up on screen. The arm spring’s natural frequency is about 1 Hz and I was feeding it a 6–7 Hz signal — the spring absorbed all of it. Naturalness from physics, paid for in fast motion. The jitter now bypasses the spring and adds straight to the arm angle.</p>

<h3 id="takeaways">Takeaways</h3>

<ul>
  <li>Pull deformation math out of the render path and tests catch what eyes can’t. Both the 12% length loss and a 1.6% trajectory divergence between 60fps and 30fps showed up that way.</li>
  <li>Measure once more before concluding “structural limit.” While the arm region was a hand-guessed bounding box, I just lowered the rotation ceiling to suppress the symptom; once the boundary was measured off the sprite itself, the cause was gone.</li>
  <li>A lesson written too broadly hides the answer from whoever reads it next. Here, that was me, a few hours later.</li>
</ul>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="webgl" />
      
        <category term="animation" />
      
        <category term="mobile" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[밤샘팟의 베개 캐릭터를 리깅 도구 없이 직접 짠 메시 변형으로 움직인 기록. 35벌이 될 뻔한 그림을 7 + 5로 줄이고, 원화에 없는 관절 때문에 팔을 여섯 번 고쳤다.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">수분 코치 - 거짓 통과 신호 10가지</title>
      <link href="https://sasostudio.dev/2026/08/27/false-pass-signals/" rel="alternate" type="text/html" title="수분 코치 - 거짓 통과 신호 10가지" />
      <published>2026-08-27T00:00:00+09:00</published>
      <updated>2026-08-27T00:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/27/false-pass-signals</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/27/false-pass-signals/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p><a href="/2026/08/27/first-launch-d1-retention-zero/">앞 글</a>에서 접은 앱 이야기를 했다. 이 글은 그 앱을 만든 한 달 동안 잡은 다른 것이다.</p>

<p>한 달 내내 반복된 일이 하나 있다. <strong>뭔가가 “통과했다”고 말하는데, 그 신호가 실제로 무엇을 봤는지는 확인이 안 된 상태.</strong> 열 번 겪었고, 그중 몇 개는 의심하지 않았으면 라이브에서 터졌을 것이다.</p>

<p>이 프로젝트는 코딩 에이전트와 짝을 이뤄 만들었다. 그래서 아래 목록에는 두 종류가 섞여 있다. 에이전트가 통과라고 보고한 걸 내가 되물어 잡은 것, 그리고 둘 다 통과라고 믿고 있다가 나중에 깨진 것. 앱은 접었지만 이 목록은 다음 프로젝트로 그대로 넘어간다.</p>

<h2 id="1-실패에-200을-붙여-보내는-서버">1. 실패에 200을 붙여 보내는 서버</h2>

<p>외부 발송 API에 첫 호출을 던졌더니 HTTP 200이 왔다. 본문을 열어 보니 발송 건수가 0이고, 사유가 “이 사용자가 수신 동의를 안 했음”이었다.</p>

<p>내 구현 계획서는 상태 코드로 성패를 가르게 돼 있었다. 그대로 만들었으면 <strong>발송이 실패했는데 성공으로 기록되고 재시도도 안 되는 조용한 버그</strong>가 났을 것이다. 실사용자 폰에서만 드러나는 종류다.</p>

<p>판정 순서를 문서에 못 박았다. 상태 코드는 “연결이 됐다”까지만 말한다. 성패는 본문이 말한다.</p>

<h2 id="2-한-명에게-한-번-보냈는데-카운트가-2">2. 한 명에게 한 번 보냈는데 카운트가 2</h2>

<p>동의를 받고 실제로 발송이 뚫린 뒤, 성공 응답에서 함정을 하나 더 건졌다. 한 사람에게 한 건 보냈는데 <strong>처리 건수가 2로 찍혔다.</strong> 푸시와 앱 안 알림함에 동시에 쌓이기 때문이었다.</p>

<p>“1이면 성공”으로 짰으면 정상 발송을 전부 실패로 기록하고 무한 재시도를 돌 뻔했다. 남의 API가 세는 단위가 내가 세는 단위와 같다고 가정하면 안 된다.</p>

<h2 id="3-대조군-없는-관측은-결론이-아니다">3. 대조군 없는 관측은 결론이 아니다</h2>

<p>두 번 겪었다.</p>

<p><strong>첫 번째.</strong> 푸시 서버를 배포하고 인증 통로를 점검했더니 “인증 정보를 찾을 수 없다”는 응답이 왔다. 에이전트는 이걸 통과로 보고했다. 내가 “그게 통과가 맞냐”고 되물었다. 서버가 인증서를 검사한 끝에 준 답인지, 인증서를 아예 안 보고 준 답인지 그 응답만으로는 구분이 안 되기 때문이다. 같은 요청을 <strong>인증서 있이 한 번, 없이 한 번</strong> 보내는 대조 검사를 만들어 돌리니 인증서 없는 쪽만 아예 거절됐고, 그제야 통과가 입증됐다.</p>

<p><strong>두 번째.</strong> 실행 환경의 외부 호출 한도를 재는데 기대치의 4배를 넘겨도 전부 통과했다. “한도가 크다”고 적고 싶었지만 <strong>무효로 기록했다.</strong> 내가 세는 대상이 실제로 제한받는 대상과 다를 수 있는데 비교 대조군이 없어서, “한도가 크다”와 “이 종류는 애초에 안 세진다”를 구분할 수 없었기 때문이다.</p>

<p>나중에 측정 방식을 갈아엎어 다시 쟀다. 서버가 자기 페이지를 반복 호출하게 만들고, 같은 실행에서 DB 조회를 51번 더 섞었다. 실패 지점이 1도 안 밀렸다. 그걸로 DB 조회는 애초에 안 세진다는 게 확정됐다. <strong>대조군 하나가 추측 몇 개를 지웠다.</strong></p>

<h2 id="4-코드를-바꿔도-통과하는-테스트">4. 코드를 바꿔도 통과하는 테스트</h2>

<p>게시 문서와 코드의 수치가 어긋나지 않도록 테스트로 묶었다고 썼다. 사실이 아니었다. 기대값을 글자 그대로 박아놔서 <strong>코드를 바꿔도 그대로 통과하는 테스트</strong>였다.</p>

<p>고친 뒤에는 실제 설정값을 읽어 기대값을 만들도록 바꾸고, 값을 일부러 세 번 틀리게 바꿔 각각 잡히는지 확인한 다음 되돌렸다.</p>

<p>비슷한 게 하나 더 있었다. 발송 인원 상한을 올렸더니 기존 경계 테스트가 <strong>조용히 무의미해졌다.</strong> 숫자가 하드코딩돼 있어서, 상한이 올라가자 테스트 대상이 전부 한 배치에 들어가 초과 상황 자체가 만들어지지 않았다. 상한 상수에서 계산되도록 고쳤다.</p>

<p>테스트가 통과하는 것과 테스트가 무언가를 지키고 있는 건 다르다. 확인하는 방법은 하나뿐이다. <strong>일부러 깨 보고 빨간불이 켜지는지 본다.</strong></p>

<h2 id="5-테스트-214건이-통과하는데-살아남은-버그">5. 테스트 214건이 통과하는데 살아남은 버그</h2>

<p>D1 카운터가 날짜가 바뀌어도 초기화되지 않는 버그가 있었다. 갱신 전 값을 기준으로 계산하는 특성 때문이었는데, 며칠 뒤부터 알림이 하루 1건으로 막히는 결함이다.</p>

<p><strong>테스트 214건이 전부 통과하는 상태였다.</strong> 이유는 단순하다. 그 지점을 강제하는 단언이 하나도 없었다. 커버리지 숫자가 높은 것과 중요한 경로가 지켜지는 건 무관하다.</p>

<p>수정할 때는 결함을 일부러 되살려 테스트가 실제로 잡는지 확인한 뒤 되돌렸다.</p>

<h2 id="6-인증서를-못-읽은-건-인증서-탓이-아니었다">6. 인증서를 못 읽은 건 인증서 탓이 아니었다</h2>

<p>서버 간 통신에 mTLS를 쓰는데 첫 연결이 계속 실패했다. “인증서가 잘못 발급된 건가” 싶었다. 여기서 재발급을 눌렀으면 멀쩡한 자산을 버리고 발급 절차를 처음부터 다시 밟았을 것이다.</p>

<p>원인은 인증서가 아니라 <strong>명령을 실행한 도구</strong>였다. Windows에 깔린 curl 두 종류가 전부 Schannel 빌드였고, Schannel은 PEM 형식 클라이언트 인증서를 읽지 못한다. 파일은 정상적으로 열었고, 그다음 PKCS#12로 해석하려다 실패한 것이다.</p>

<p>결정적 단서는 오류 문구였다. “파일을 못 열었다”가 아니라 <strong>“읽어서 해석하는 데 실패했다”</strong> 였다. 이 한 줄이 경로 문제와 권한 문제를 동시에 배제해 줬다. 도구를 Node/OpenSSL로 바꿔 다시 던지니 한 번에 통과했다.</p>

<p>여기서 살린 건 코드가 아니라 방침이다. <strong>“원인을 모를 때 재발급하지 않는다.”</strong> 이 원칙이 실제로 멀쩡한 걸 버리는 사고를 막았다.</p>

<h2 id="7-주석은-검증이-아니다">7. 주석은 검증이 아니다</h2>

<p>실기기 점검에서 결함 하나가 잡혔다. 알림을 끄려다 실패했는데 <strong>화면 스위치만 꺼졌다.</strong> 기기에도 서버에도 켜짐 기록이 남아 있으니, 사용자는 껐다고 믿는데 알림은 계속 갈 수 있는 상태였다.</p>

<p>원인은 켜기와 끄기 두 흐름을 한 줄로 판정한 코드였다. 그런데 그 코드 바로 위 주석에는 <strong>“실패하면 되돌린다”</strong>고 적혀 있었다. 의도는 정확히 적혀 있었고 구현이 그걸 안 지켰다.</p>

<p>주석은 의도의 기록이지 동작의 증거가 아니다. 헷갈리기 쉬운 이유는 주석이 코드보다 읽기 편하고 대체로 더 그럴듯하기 때문이다.</p>

<h2 id="8-에러-없이-조용히-죽는-캐시">8. 에러 없이 조용히 죽는 캐시</h2>

<p>운영비를 계산하다가 예상 밖의 걸 발견했다. <strong>가장 먼저 무너지는 한도가 사용자 수와 무관했다.</strong></p>

<p>날씨 캐시를 지역별로 저장하는 구조라, KV 쓰기 한도가 사용자 수가 아니라 <strong>서로 다른 지역 개수</strong>에 걸린다. 대략 20개 지역이면 상한이었다.</p>

<p>더 나쁜 건 초과했을 때의 동작이다. 캐시 저장 실패는 일부러 조용히 넘기게 짜뒀다. 캐시가 안 되는 건 기능 장애가 아니니 크래시를 내면 안 된다고 판단했던 것이다. 그래서 이런 경로가 생긴다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">단계</th>
      <th>일어나는 일</th>
      <th>어디서 보이나</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">1</td>
      <td>고유 지역이 20개를 넘는다</td>
      <td>안 보임</td>
    </tr>
    <tr>
      <td style="text-align: center">2</td>
      <td>캐시 쓰기가 실패하기 시작 (에러 없음)</td>
      <td>안 보임</td>
    </tr>
    <tr>
      <td style="text-align: center">3</td>
      <td>캐시가 사실상 죽고 외부 API 호출이 사용자 수에 비례해 폭증</td>
      <td>클라우드 대시보드만</td>
    </tr>
    <tr>
      <td style="text-align: center">4</td>
      <td>기상청 쿼터 초과 → 날씨 기능 전면 정지</td>
      <td>사용자가 먼저 안다</td>
    </tr>
  </tbody>
</table>

<p>우리 로그에는 3번까지 아무것도 안 잡힌다. <strong>조용한 열화는 조용한 만큼 늦게 발견된다.</strong> 해소 자체는 월 몇 달러짜리 유료 전환 하나로 병목 네 개가 동시에 풀리는 구조라, 인프라 비용은 사실상 문제가 아니었다. 문제는 그 시점을 관측할 수 있느냐였다.</p>

<h2 id="9-로그가-보존됐다는-증거는-pc가-꺼져-있었다는-사실이었다">9. 로그가 보존됐다는 증거는 PC가 꺼져 있었다는 사실이었다</h2>

<p>“사용자가 늘어 알림이 상한에 밀렸을 때 그걸 알 방법이 있나”를 규명했다. 결론은 외부 콘솔로는 원리적으로 불가능이었다. 상한에 걸려 밀린 사람에겐 발송 요청 자체를 안 보내니 외부 통계에 흔적이 0이다. 외부는 “보낸 것”만 안다.</p>

<p>밀린 인원을 세는 지표는 이미 서버에 있었다. 없던 건 판단 로직이 아니라 <strong>로그 보존</strong>이었다. 실시간 스트림으로만 볼 수 있어서, 아침 특정 시각 기록을 보려면 매일 그 시간에 터미널을 켜고 있어야 했다.</p>

<p>보존을 켜고 검증했는데 통과 근거가 재미있었다. 한 시간 전 기록이 조회됐다는 것만으로는 부족하다. 실시간으로 보고 있었을 가능성이 남기 때문이다. 결정타는 <strong>그 시각에 PC가 꺼져 있었다는 사실</strong>이었다. 관측자가 존재할 수 없었으므로 그건 보존된 기록이다.</p>

<p>두 신호가 서로 다른 걸 답한다는 것도 문서에 못 박았다.</p>

<table>
  <thead>
    <tr>
      <th>신호</th>
      <th>아는 것</th>
      <th>모르는 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>내부 지표</td>
      <td>안 보낸 것 (상한에 밀림)</td>
      <td>보낸 게 도착했는지</td>
    </tr>
    <tr>
      <td>외부 통계</td>
      <td>보냈는데 안 닿은 것</td>
      <td>애초에 안 보낸 건</td>
    </tr>
  </tbody>
</table>

<h2 id="10-범위를-안-밝힌-전수-주장">10. 범위를 안 밝힌 전수 주장</h2>

<p>검수 단계에서 보고 하나가 반증된 적이 있다. “전수 확인했고 0건”이라고 단언했는데, 규범 문서만 훑고 이력 문서를 빼먹은 상태였다.</p>

<p>결론 자체는 영향이 없었다. 문제는 그게 <strong>검증 없는 단언</strong>이었다는 것이다. 이후로는 확인한 파일 범위를 함께 적기로 했다.</p>

<p><strong>범위를 안 밝힌 전수 주장은 애초에 검증할 수 없는 주장이다.</strong> 반증하려는 사람이 어디를 봐야 하는지 알 수 없기 때문이다.</p>

<h2 id="하나로-줄이면">하나로 줄이면</h2>

<p>열 개가 전부 같은 모양이다. 어떤 신호가 통과라고 말하는데, 그 신호가 <strong>실제로 무엇을 봤는지</strong>는 확인되지 않았다.</p>

<ul>
  <li>200은 연결을 봤지 발송을 안 봤다</li>
  <li>처리 건수 2는 두 채널을 봤지 사람 수를 안 봤다</li>
  <li>초록불 214개는 코드를 봤지 자정 넘김을 안 봤다</li>
  <li>조회된 로그는 기록을 봤지 그게 보존된 건지는 안 봤다</li>
</ul>

<p>그래서 물어야 할 질문은 “통과했나”가 아니다. <strong>“이 신호가 실패했다면 어떻게 달라 보였을까”</strong> 다. 답이 “똑같아 보였을 것”이면 그건 통과가 아니라 아무것도 아니다.</p>

<p>앱은 접었다. 이 목록은 안 접었다.</p>

<hr />

<h2 id="english-version">English version</h2>

<p><a href="/2026/08/27/first-launch-d1-retention-zero/">The previous post</a> was about the app I shut down. This one is about something else I caught during the month I spent building it.</p>

<p>One thing kept recurring: <strong>something reports “pass,” but nobody has checked what that signal actually looked at.</strong> It happened ten times, and a few of them would have blown up in production if I hadn’t pushed on them.</p>

<p>I built this project paired with a coding agent, so the list below mixes two kinds of case: ones where the agent reported a pass and I pushed back, and ones where both of us believed the pass until it broke later. The app is dead; the list carries forward.</p>

<h3 id="1-a-server-that-stamps-200-on-failure">1. A server that stamps 200 on failure</h3>

<p>First call to an external send API came back HTTP 200. Opened the body: zero sends, reason “this user has not consented to receive notifications.”</p>

<p>My implementation plan decided success from the status code. Built as written, that produces <strong>a silent bug where a failed send is recorded as success and never retried</strong> — the kind that only surfaces on a real user’s phone.</p>

<p>I pinned the order of judgment into the doc. The status code tells you the connection happened. The body tells you whether anything worked.</p>

<h3 id="2-one-send-to-one-person-counted-as-two">2. One send to one person, counted as two</h3>

<p>After consent came through and sends actually started landing, the success response handed me another trap. One notification to one person came back with <strong>a processed count of 2</strong> — because it lands in the push tray and the in-app inbox at the same time.</p>

<p>Had I coded “1 means success,” every healthy send would have been logged as a failure and retried forever. Never assume somebody else’s API counts the same unit you do.</p>

<h3 id="3-an-observation-without-a-control-is-not-a-conclusion">3. An observation without a control is not a conclusion</h3>

<p>Twice.</p>

<p><strong>First.</strong> I deployed the push server and probed the auth path. The response said “credentials not found.” The agent reported that as a pass. I pushed back — that response alone can’t distinguish a server that inspected my certificate from one that never looked at it. So we built a paired check, the same request <strong>once with the client certificate, once without.</strong> Only the certificate-less call was rejected outright. That’s when the pass was actually established.</p>

<p><strong>Second.</strong> Measuring the runtime’s outbound-call limit, I blew past 4× the expected ceiling with everything still succeeding. I wanted to write down “the limit is generous.” I <strong>recorded the measurement as void instead.</strong> What I was counting might not be what’s actually rate-limited, and with no control group I couldn’t distinguish “the limit is high” from “this kind of call isn’t counted at all.”</p>

<p>Later I rebuilt the measurement: had the server hammer its own endpoint, and mixed in 51 extra DB queries in the same run. The failure point didn’t shift by one. That settled it — DB queries aren’t counted. <strong>One control erased several guesses.</strong></p>

<h3 id="4-a-test-that-passes-no-matter-what-the-code-does">4. A test that passes no matter what the code does</h3>

<p>I wrote that the published document and the code were now bound together by a test, so they couldn’t drift. Not true. The expected value was hardcoded, so <strong>the test passed regardless of what the code said.</strong></p>

<p>I fixed it to derive expectations from the actual config, then deliberately broke the value three different ways to confirm each one got caught, then reverted.</p>

<p>There was a sibling. When I raised the per-batch send cap, an existing boundary test <strong>quietly became meaningless</strong> — the count was hardcoded, so once the cap went up every test subject fit in one batch and the overflow case stopped existing. I made it compute from the cap constant.</p>

<p>A passing test and a test that’s guarding something are different things. There’s exactly one way to tell: <strong>break it on purpose and watch for red.</strong></p>

<h3 id="5-a-bug-that-survived-214-passing-tests">5. A bug that survived 214 passing tests</h3>

<p>A D1 counter failed to reset across midnight, because of how the database evaluates against the pre-update value. Symptom: a few days in, notifications get capped at one per day.</p>

<p><strong>All 214 tests were green.</strong> Simple reason — not one assertion forced that path. A high coverage number and your important paths being protected are unrelated facts.</p>

<p>Fixing it, I reintroduced the defect on purpose to confirm the new test actually caught it, then reverted.</p>

<h3 id="6-the-certificate-wasnt-the-problem">6. The certificate wasn’t the problem</h3>

<p>Server-to-server calls use mTLS, and the first connection kept failing. My instinct was that the certificate had been issued wrong. Had I hit reissue there, I’d have thrown away a perfectly good asset and restarted the whole provisioning process.</p>

<p>The cause was the <strong>tool running the command.</strong> Both curl builds installed on Windows were Schannel builds, and Schannel can’t read PEM client certificates. It opened the file just fine, then failed trying to parse it as PKCS#12.</p>

<p>The decisive clue was the wording of the error. Not “couldn’t open the file” but <strong>“failed to read and parse it.”</strong> That single line ruled out path problems and permission problems simultaneously. Switching to Node/OpenSSL, the same request passed on the first try.</p>

<p>What saved me wasn’t code, it was a policy: <strong>don’t reissue while the cause is unknown.</strong> That rule prevented a real loss.</p>

<h3 id="7-a-comment-is-not-verification">7. A comment is not verification</h3>

<p>An on-device check surfaced a defect: turning notifications <em>off</em> failed, but <strong>the UI switch flipped anyway.</strong> The enabled record persisted on both device and server, so the user believes they’re off while notifications can still arrive.</p>

<p>The cause was a single line judging both the enable and disable flows the same way. And the comment directly above that line read <strong>“revert on failure.”</strong> The intent was stated precisely. The implementation ignored it.</p>

<p>A comment records intent, not behavior. It’s easy to be fooled because comments are more readable than code, and usually more convincing.</p>

<h3 id="8-a-cache-that-dies-silently">8. A cache that dies silently</h3>

<p>Working out the running costs, I found something I didn’t expect: <strong>the first limit to break had nothing to do with user count.</strong></p>

<p>Weather responses cache per region, so the KV write limit binds on <strong>the number of distinct regions</strong>, not users. Roughly 20 regions was the ceiling.</p>

<p>Worse is what happens past it. I’d deliberately made cache-write failures swallow silently — a cache miss isn’t a functional outage, so it shouldn’t crash anything. Which creates this path:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Step</th>
      <th>What happens</th>
      <th>Where it’s visible</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">1</td>
      <td>Distinct regions exceed ~20</td>
      <td>nowhere</td>
    </tr>
    <tr>
      <td style="text-align: center">2</td>
      <td>Cache writes start failing (no error)</td>
      <td>nowhere</td>
    </tr>
    <tr>
      <td style="text-align: center">3</td>
      <td>Cache effectively dies; upstream API calls scale with users</td>
      <td>cloud dashboard only</td>
    </tr>
    <tr>
      <td style="text-align: center">4</td>
      <td>KMA quota exceeded → weather features stop entirely</td>
      <td>users notice first</td>
    </tr>
  </tbody>
</table>

<p>Nothing in my own logs registers through step 3. <strong>Silent degradation is discovered exactly as late as it is silent.</strong> The fix was easy — a few dollars a month unlocks four bottlenecks at once, so infrastructure cost was never the real issue. The issue was whether I could see the moment arrive.</p>

<h3 id="9-the-proof-the-logs-persisted-was-that-the-pc-was-off">9. The proof the logs persisted was that the PC was off</h3>

<p>I set out to answer: if user growth pushes notifications past the send cap, is there any way to know? Externally, no — structurally impossible. Anyone dropped by the cap never gets a send request in the first place, so they leave zero trace in external stats. Outside systems only know what was sent.</p>

<p>The metric counting dropped recipients already existed server-side. What was missing wasn’t logic, it was <strong>log retention.</strong> Records were visible only as a live stream, so seeing a specific morning’s run meant sitting at a terminal at that hour every day.</p>

<p>I enabled retention and verified it, and the grounds for the pass are my favorite part. Retrieving an hour-old record isn’t sufficient on its own, because I might have been streaming it live. The clincher was <strong>that the machine was powered off at that time.</strong> No observer could have existed, therefore the record was retained.</p>

<p>I also pinned down that the two signals answer different questions:</p>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>Knows</th>
      <th>Doesn’t know</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Internal metric</td>
      <td>what was never sent (dropped by cap)</td>
      <td>whether sends arrived</td>
    </tr>
    <tr>
      <td>External stats</td>
      <td>what was sent but didn’t land</td>
      <td>what was never sent</td>
    </tr>
  </tbody>
</table>

<h3 id="10-an-exhaustiveness-claim-with-no-stated-scope">10. An exhaustiveness claim with no stated scope</h3>

<p>A review pass refuted one of the reports. It asserted “checked exhaustively, zero occurrences” on the basis of the normative docs alone, having skipped the history docs.</p>

<p>The conclusion happened to be unaffected. The problem was that it was <strong>an assertion without verification.</strong> Since then I state the file scope I actually covered.</p>

<p><strong>An exhaustiveness claim with no stated scope isn’t verifiable in principle</strong> — whoever wants to refute it has no way to know where to look.</p>

<h3 id="compressed-to-one-thing">Compressed to one thing</h3>

<p>All ten have the same shape. A signal reports pass, and nobody checked <strong>what that signal actually observed.</strong></p>

<ul>
  <li>The 200 observed a connection, not a delivery</li>
  <li>The count of 2 observed two channels, not two people</li>
  <li>The 214 green checks observed the code, not the midnight rollover</li>
  <li>The retrieved log observed a record, not whether it was retained</li>
</ul>

<p>So the question isn’t “did it pass.” It’s <strong>“if this had failed, what would look different?”</strong> If the answer is “nothing,” you don’t have a pass. You have nothing.</p>

<p>The app is shut down. The list isn’t.</p>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="build-in-public" />
      
        <category term="engineering" />
      
        <category term="testing" />
      
        <category term="observability" />
      
        <category term="indie-dev" />
      

      
      
        <summary type="html"><![CDATA[수분 코치를 만든 한 달 동안 「통과했다」는 신호가 실제로는 아무것도 보지 않았던 10가지 사례. 실패에 붙은 HTTP 200, 코드를 바꿔도 통과하는 테스트, 214건이 통과하는 가운데 살아남은 버그 등.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">수분 코치 - 첫 출시작의 D1 리텐션 0</title>
      <link href="https://sasostudio.dev/2026/08/27/first-launch-d1-retention-zero/" rel="alternate" type="text/html" title="수분 코치 - 첫 출시작의 D1 리텐션 0" />
      <published>2026-08-27T00:00:00+09:00</published>
      <updated>2026-08-27T00:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/08/27/first-launch-d1-retention-zero</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/08/27/first-launch-d1-retention-zero/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p>열 번째 프로젝트를 접었다. 앞선 아홉 개와 다른 점이 하나 있다. 그것들은 전부 출시 전에 죽었고, 사용자를 한 명도 만나지 못한 채 “이건 아닌 것 같다”로 끝났다. 수분 코치는 심사를 통과했고, 출시됐고, 실사용자 30명을 만났다. 그러고 나서 데이터가 앱을 기각했다.</p>

<p>착수부터 종료까지 31일, 실작업은 20일쯤이다. 코드 약 18,585줄에 파일 134개. 토스 미니앱 플랫폼(앱인토스)의 7월 챌린지 출품작이었다.</p>

<h2 id="기상청-오픈api-위에-세운-앱">기상청 오픈API 위에 세운 앱</h2>

<p>물 마시기 앱은 흔하다. 흔하지 않게 만들려고 목표량을 날씨에서 뽑았다. 오늘 덥고 습하면 목표가 올라가고, 폭염경보가 뜨면 한 번 더 올라간다.</p>

<p>재료는 전부 공공데이터포털의 기상청 오픈API였다. <code class="language-plaintext highlighter-rouge">apis.data.go.kr/1360000</code> 아래 네 종류를 썼다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>오퍼레이션</th>
      <th>쓴 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>단기예보 조회서비스</td>
      <td><code class="language-plaintext highlighter-rouge">getVilageFcst</code></td>
      <td>기온·습도 → 체감온도 → 목표량</td>
    </tr>
    <tr>
      <td>단기예보 조회서비스</td>
      <td><code class="language-plaintext highlighter-rouge">getUltraSrtNcst</code></td>
      <td>예보 말고 실제 관측값</td>
    </tr>
    <tr>
      <td>기상특보 조회서비스</td>
      <td><code class="language-plaintext highlighter-rouge">getWthrWrnList</code></td>
      <td>폭염주의보·경보</td>
    </tr>
    <tr>
      <td>생활기상지수 조회서비스 V5</td>
      <td><code class="language-plaintext highlighter-rouge">getUVIdxV5</code></td>
      <td>자외선 지수</td>
    </tr>
  </tbody>
</table>

<p><strong>안 쓴 API가 더 중요했다.</strong> 체감온도지수(<code class="language-plaintext highlighter-rouge">getSenTaIdxV4</code>)는 2026년 5월 1일자로 종료됐다. 그래서 기온과 습도만으로 체감온도를 직접 산출했다. 외부 지수를 하나 더 받아오는 대신 계산식을 코드에 넣은 셈인데, 결과적으로 이게 나았다. 호출이 하나 줄었고, 종료 공지에 흔들릴 의존성도 하나 줄었다.</p>

<p>호출은 앱에서 직접 하지 않고 Cloudflare Worker 프록시를 세워 중계했다. 이유는 두 가지다.</p>

<ul>
  <li><strong>API 키 은닉.</strong> 키는 Worker Secret에만 있고 앱 번들에는 존재하지 않는다.</li>
  <li><strong>위치정보 미수집.</strong> 앱은 GPS를 쓰지 않는다. 사용자가 고른 지역의 격자 좌표(nx/ny)와 지점 코드만 보내고, 프록시는 위경도를 아예 인지하지 않는다. 시/도·시군구 256건을 격자·지점 코드로 미리 매핑해 뒀다.</li>
</ul>

<p>캐시는 KV에 1시간, 키를 “격자 좌표 + 발표 시각 슬롯”으로 잡아 전 사용자가 공유하게 했다. 기상청 쿼터를 지키려는 설계였는데 나중에 이게 비용 구조의 첫 병목이 된다. 그 얘기는 <a href="/2026/08/27/false-pass-signals/">2편</a>에서 다룬다.</p>

<h2 id="이미-출시된-버전에서-폭염-배너가-죽어-있었다">이미 출시된 버전에서 폭염 배너가 죽어 있었다</h2>

<p>두 번째 버전을 심사에 내기 직전, 잠복 결함을 하나 발견했다.</p>

<p>특보 통보문을 파싱하면서 <code class="language-plaintext highlighter-rouge">발표</code>와 <code class="language-plaintext highlighter-rouge">해제</code>만 읽고 있었다. 그런데 기상청은 등급이 바뀔 때 <code class="language-plaintext highlighter-rouge">변경</code>으로 통보한다. 폭염주의보가 경보로 격상되면 통보문 종류가 <code class="language-plaintext highlighter-rouge">변경</code>이고, 우리 코드는 그걸 못 읽으니 <strong>그 시점부터 특보를 “없음”으로 판정</strong>했다.</p>

<p>파장이 알림에만 그치지 않았다. 같은 판정 결과를 앱 화면의 폭염 배너와 목표량 상향이 함께 쓰고 있었다. 즉 <strong>이미 출시돼 있던 버전에서, 폭염경보가 떠도 배너가 안 뜨고 목표량도 안 올라가고 있었다.</strong> 앱의 정체성에 해당하는 기능이 조용히 죽어 있던 것이다.</p>

<p>고칠 때는 목록을 늘리는 대신 판정을 뒤집었다. <code class="language-plaintext highlighter-rouge">발표</code>·<code class="language-plaintext highlighter-rouge">변경</code>을 나열하는 방식은 기상청이 새 표기를 하나 더 쓰는 순간 또 뚫린다. 그래서 <strong>“해제가 아니면 유효”</strong> 로 바꾸고, 실제로 받은 통보문 원문을 테스트에 박았다.</p>

<h2 id="실측-22명-중-다음-날-다시-연-사람-0명">실측: 22명 중 다음 날 다시 연 사람 0명</h2>

<p>8월 초에 유입 광고를 돌렸다.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>수치</th>
      <th>판정</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>도달</td>
      <td>5,201건</td>
      <td>정상</td>
    </tr>
    <tr>
      <td>클릭</td>
      <td>27건 (CTR 0.52%)</td>
      <td>업계 정상 범위</td>
    </tr>
    <tr>
      <td>신규 유입</td>
      <td>16명 (8/8~8/11)</td>
      <td>광고는 제 일을 했다</td>
    </tr>
    <tr>
      <td><strong>재방문</strong></td>
      <td><strong>0명</strong></td>
      <td>🔴</td>
    </tr>
  </tbody>
</table>

<p>코호트로 추적한 22명 전원이 D1에 돌아오지 않았다. 건강·습관 앱의 D1 벤치마크는 20~30%다.</p>

<p>표본이 작아서 그런 것 아니냐고 물을 수 있다. 계산해 봤다. <strong>실제 D1이 10%라고 가정해도 22명 전원이 미복귀할 확률은 9.8%다.</strong> 이 결과가 우연이려면 열 번에 한 번 나오는 일이 하필 일어난 것이어야 한다. 우리 D1은 10% 미만으로 봐야 한다.</p>

<p>꾸준히 들어오는 사용자가 딱 한 명 있었는데, 정황상 만든 사람 본인이다.</p>

<p><strong>마케팅 문제가 아니다. 0에 5,000을 곱해도 0이다.</strong></p>

<h2 id="왜-헛돌았나">왜 헛돌았나</h2>

<p>접으면서 규명한 것 중 제일 값진 게 실패 원인이다. 셋인데 첫 번째는 처음 보는 패턴이었다.</p>

<h3 id="1-심사-방어-논리를-제품-가치로-착각했다">1. 심사 방어 논리를 제품 가치로 착각했다</h3>

<p>“날씨로 목표량을 계산한다”가 이 앱의 차별점이라고 믿었다. 그런데 그 논리는 <strong>원래 심사용이었다.</strong> 그냥 물 마시기 앱으로 내면 단순 리워드성 미니앱으로 읽힐까 봐, “고정값 2L가 아니라 기상 데이터 기반 산출”이라는 근거를 준비한 것이다. 우리 현황서에도 그 맥락으로 “계산 알고리즘이 정체성”이라고 적혀 있었다.</p>

<p>같은 문장이 두 사람에게 전혀 다르게 읽힌다.</p>

<table>
  <thead>
    <tr>
      <th>읽는 사람</th>
      <th>반응</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>심사관</td>
      <td>“고정값이 아니라 기상 데이터 기반 산출” → 통과</td>
    </tr>
    <tr>
      <td>사용자</td>
      <td>“오늘 목표가 1,900ml네” → <strong>그래서 뭐</strong></td>
    </tr>
  </tbody>
</table>

<p>체감온도가 목표를 1,800에서 1,900으로 바꾼다는 사실은 앱을 매일 열 이유가 못 된다. <strong>심사를 통과시키는 논리와 사람이 앱을 여는 이유는 다른 물건이다.</strong></p>

<p>체크리스트에 항목을 하나 추가했다. <em>“이 차별점은 규정 대응용인가, 사용자 가치인가?”</em> 규정 대응용이면 사용자가 매일 여는 이유를 따로 대야 한다.</p>

<h3 id="2-계측-없이-유입부터-태웠다">2. 계측 없이 유입부터 태웠다</h3>

<p>온보딩 완료·물 기록·목표 달성, 이 세 이벤트 계측은 끝까지 코드에 안 들어갔다. 현황서에 미완료 표시로 계속 남아 있었는데, 그 상태로 광고 5,201건을 다 썼다.</p>

<table>
  <thead>
    <tr>
      <th>알고 싶은 것</th>
      <th>알 수 있나</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>지역 선택 3단계 온보딩에서 이탈했나</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>물 한 번 기록하고 “이게 다야?” 했나</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>기록은 했는데 다음 날 잊었나</td>
      <td>❌</td>
    </tr>
  </tbody>
</table>

<p>셋은 처방이 완전히 다르다. 각각 온보딩 축소, 가치 제시 재설계, 알림 옵트인이다. 그런데 <strong>22명이 어디서 새어나갔는지 이제 영원히 알 수 없다.</strong> 유입 실험 한 번을 통째로 날린 셈이다.</p>

<p>원칙으로 남겼다. <strong>유입을 태우기 전에 깔때기 계측부터.</strong> 계측 없는 유입은 데이터가 아니라 소음이다.</p>

<h3 id="3-사용자가-0명일-때-재방문-엔진을-3주-만들었다">3. 사용자가 0명일 때 재방문 엔진을 3주 만들었다</h3>

<p>개인화 푸시에 3주를 넣었다. mTLS 클라이언트 인증, D1, cron 판정, 발송 배치, fail-closed 설계, 법적 문서 게시까지 전부 만들었다.</p>

<p><strong>완성 시점에 알림을 켠 사용자는 1명이었다.</strong> 그것도 정황상 나다.</p>

<p>22명이 앱에 들어왔는데 아무도 알림을 켜지 않았다. 재방문을 만드는 유일한 훅이 사실상 아무에게도 연결되지 않은 채 완성된 것이다.</p>

<p>이건 직전 프로젝트에서 이미 겪은 패턴이다. 「끝장」에서 사용자가 없는 채로 멀티플레이를 구현해 실제 대전이 불가능했던 것과 같다. 형태만 멀티플레이에서 푸시 엔진으로 바뀌었다. 푸시 엔진 3주 대신 <strong>옵트인 전환율 측정 3시간</strong>이 먼저였어야 했다.</p>

<p>여기에 시한 요인이 하나 더 붙었다. 체감온도와 폭염특보가 목표를 흔드는 게 이 앱의 정체성인데, 9월이면 그 변수가 사라진다. 이름만 계절 중립으로 바꿨을 뿐 기능은 여름 그대로였고, <strong>7월 중순 착수에 8월 출시라 검증 창이 구조적으로 4주밖에 없었다.</strong></p>

<h2 id="잘-만든-것과-내-실수">잘 만든 것과 내 실수</h2>

<p>fail-closed 설계는 만족스러웠다. “켠 적 없는 사용자에게는 안 보낸다”를 서버가 강제하도록 짰는데, 이게 <strong>라이브 로그에서 실제로 작동하는 걸 관측</strong>했다. 옵트인 행이 DB에 있는데도 발송 후보가 0건으로 찍히는 순간이 있었고 그게 설계대로였다. 코드가 그렇게 돼 있다는 것과 운영 중에 그렇게 동작하는 걸 봤다는 건 무게가 다르다.</p>

<p>반대로 사고도 하나 기록해 뒀다. 이 프로젝트는 코딩 에이전트와 짝을 이뤄 만들었는데, 설정을 추가할지 나에게 묻고 대기하던 상황에서 에이전트가 <strong>실행할 명령 블록을 먼저 보여주고 질문을 그 뒤에 붙였다.</strong> 나는 이미 반영된 걸로 읽고 배포를 실행했다. 다행히 같은 코드 재배포라 사고는 없었지만 목적 기능은 안 켜졌다.</p>

<p>교훈은 이렇다. <strong>실행 명령을 제시할 거면 변경을 먼저 적용해 두거나, 아직 적용 전임을 명령 블록 안에 박아라.</strong> 명령 블록은 그 자체로 “준비됐다”는 신호로 읽힌다. 사람이든 에이전트든 마찬가지다.</p>

<h2 id="삭제가-아니라-동결">삭제가 아니라 동결</h2>

<p>접는 방식은 동결로 정했다. 앱은 라이브에 그대로 두고 개발만 중단한다.</p>

<p>이유는 두 가지다. 유지비가 실제로 월 0원이라 내릴 이유가 비용이 아니다. 그리고 심사 통과부터 출시까지 뚫어놓은 경로 자체가 다음 프로젝트를 며칠 단축시키는 자산이다. 재사용 가능한 것 13종을 목록으로 남겼다. mTLS 푸시 서버, 기상청 4종 프록시, 체감온도 자체 산출 로직, 로그인 없이 익명 키로 사용자를 식별하는 노선, 법적 문서 3종과 게시 라우트 같은 것들이다.</p>

<p>다만 <strong>동결해도 안 사라지는 의무가 하나</strong> 있다. 개인정보 처리방침을 실제로 게시했고 거기에 파기 조항을 적었다. 앱을 안 내려도 그 약속은 유효하다. 기한을 문서에 못 박아 뒀다. 게시한 고지는 개발을 멈춘다고 같이 멈추지 않는다.</p>

<h2 id="남은-것">남은 것</h2>

<p>기술적으로는 성공했고 제품으로는 실패했다. 열 번째 같은 결론이다.</p>

<p>다만 이번엔 처음으로 <strong>실사용자 데이터가 그 결론을 내렸다.</strong> 유통 채널도 열렸고, 정책 리스크도 넘었고, 외부 API도 다 붙었다. 그 상태에서 실패했다는 건 남은 병목이 <strong>만드는 능력이 아니라 무엇을 만들지 고르는 단계</strong>라는 뜻이다.</p>

<p>수분 코치는 애초에 걸렸어야 할 질문에서 걸렸다. <em>“이 문제 때문에 지난주에 실제로 불편했는가?”</em> 물을 안 마셔서 불편한 적은 없다. 안 마셔도 당장 아프지 않고, 정말 필요할 땐 이미 목이 마르다. 앱이 없애주는 불편이 앱을 여는 수고보다 작았다.</p>

<p>다음 프로젝트의 과제는 더 잘 만드는 게 아니라 그 질문을 실제로 통과하는 것이다.</p>

<hr />

<h2 id="english-version">English version</h2>

<p>I shut down my tenth project. This one differed from the previous nine in a single way: those all died before launch, ending on a vague “this isn’t it” without ever meeting a user. Hydro Coach passed review, shipped, and reached 30 real users. Then the data rejected it.</p>

<p>Thirty-one days start to finish, about twenty of them actual work. About 18,585 lines across 134 files. It was my entry for the July challenge on Apps in Toss, Toss’s mini-app platform.</p>

<h3 id="built-on-koreas-public-weather-api">Built on Korea’s public weather API</h3>

<p>Water-tracking apps are a dime a dozen. To make one that wasn’t, I derived the daily target from the weather. Hot and humid today, the target goes up. Heat advisory in effect, it goes up again.</p>

<p>Every input came from the Korea Meteorological Administration’s open API, served through the national open-data portal at <code class="language-plaintext highlighter-rouge">apis.data.go.kr/1360000</code>. Four endpoints:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Operation</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Short-term forecast</td>
      <td><code class="language-plaintext highlighter-rouge">getVilageFcst</code></td>
      <td>temp + humidity → apparent temp → target</td>
    </tr>
    <tr>
      <td>Short-term forecast</td>
      <td><code class="language-plaintext highlighter-rouge">getUltraSrtNcst</code></td>
      <td>actual observations, not forecasts</td>
    </tr>
    <tr>
      <td>Weather warnings</td>
      <td><code class="language-plaintext highlighter-rouge">getWthrWrnList</code></td>
      <td>heat advisories and warnings</td>
    </tr>
    <tr>
      <td>Living weather index V5</td>
      <td><code class="language-plaintext highlighter-rouge">getUVIdxV5</code></td>
      <td>UV index</td>
    </tr>
  </tbody>
</table>

<p><strong>The endpoint I didn’t use mattered more.</strong> The apparent-temperature index (<code class="language-plaintext highlighter-rouge">getSenTaIdxV4</code>) was retired on May 1, 2026. So I computed apparent temperature myself from temperature and humidity alone. Trading one more remote call for a formula in my own code turned out to be the better deal: one fewer request, and one fewer dependency that a deprecation notice can knock over.</p>

<p>The app never calls the API directly. A Cloudflare Worker proxy sits in between, for two reasons:</p>

<ul>
  <li><strong>Key concealment.</strong> The API key lives only in a Worker Secret and never appears in the app bundle.</li>
  <li><strong>No location collection.</strong> The app doesn’t touch GPS. It sends only the grid coordinates (nx/ny) and station code for a region the user picked from a list, and the proxy never sees latitude or longitude at all. I pre-mapped 256 administrative regions to their grid and station codes.</li>
</ul>

<p>Responses cache in KV for an hour, keyed by grid coordinates plus the forecast’s publication slot, so every user shares the same entry. That was there to respect the KMA’s quota. It later turned out to be the first bottleneck in the cost structure — <a href="/2026/08/27/false-pass-signals/">part two</a> covers that.</p>

<h3 id="the-heat-banner-was-dead-in-a-version-already-shipped">The heat banner was dead in a version already shipped</h3>

<p>Right before submitting the second version for review, I found a latent defect.</p>

<p>My warning parser read only <code class="language-plaintext highlighter-rouge">발표</code> (issued) and <code class="language-plaintext highlighter-rouge">해제</code> (lifted). But the KMA sends <code class="language-plaintext highlighter-rouge">변경</code> (changed) when a warning is upgraded or downgraded. When a heat advisory escalates to a heat warning, the notice type is <code class="language-plaintext highlighter-rouge">변경</code> — which my code couldn’t read, so <strong>from that moment on it judged there to be no warning at all.</strong></p>

<p>The blast radius went past notifications. The same judgment fed the in-app heat banner and the target increase. Which means <strong>in a version that was already live, a heat warning could be in effect while the banner stayed hidden and the target never rose.</strong> The feature the whole app was built around was silently dead.</p>

<p>Fixing it, I inverted the test rather than extending the list. Enumerating <code class="language-plaintext highlighter-rouge">발표</code> and <code class="language-plaintext highlighter-rouge">변경</code> breaks again the moment the KMA introduces one more label. So it became <strong>“valid unless lifted,”</strong> with the real notice text I’d received pinned into a test.</p>

<h3 id="the-measurement-zero-of-22-came-back-the-next-day">The measurement: zero of 22 came back the next day</h3>

<p>I ran an acquisition campaign in early August.</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Number</th>
      <th>Verdict</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Impressions</td>
      <td>5,201</td>
      <td>normal</td>
    </tr>
    <tr>
      <td>Clicks</td>
      <td>27 (0.52% CTR)</td>
      <td>within industry range</td>
    </tr>
    <tr>
      <td>New users</td>
      <td>16 (Aug 8–11)</td>
      <td>the ads did their job</td>
    </tr>
    <tr>
      <td><strong>Returned</strong></td>
      <td><strong>0</strong></td>
      <td>🔴</td>
    </tr>
  </tbody>
</table>

<p>All 22 users in the tracked cohort failed to return on D1. The D1 benchmark for health and habit apps is 20–30%.</p>

<p>Small sample, you might object. I ran the numbers. <strong>Even assuming a true D1 of 10%, the chance that all 22 fail to return is 9.8%.</strong> For this result to be luck, a one-in-ten event would have to have landed on me. The honest read is that my D1 is under 10%.</p>

<p>Exactly one user kept coming back, and circumstantially that user is me.</p>

<p><strong>This is not a marketing problem. Multiply zero by five thousand and you still have zero.</strong></p>

<h3 id="three-reasons-it-went-nowhere">Three reasons it went nowhere</h3>

<p>The most valuable thing I got out of shutting it down was the diagnosis. Three causes, and the first one was new to me.</p>

<p><strong>1. I mistook a review-defense argument for product value.</strong> I believed “the target is calculated from weather” was the app’s differentiator. That argument was <strong>built for the reviewers.</strong> I was worried a plain water tracker would read as a trivial reward app, so I prepared grounds to say “not a flat 2L, but derived from meteorological data.” My own status doc says “the calculation algorithm is the identity” — in exactly that context.</p>

<p>The same sentence lands completely differently depending on who reads it:</p>

<table>
  <thead>
    <tr>
      <th>Reader</th>
      <th>Reaction</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Reviewer</td>
      <td>“derived from weather data, not a constant” → approved</td>
    </tr>
    <tr>
      <td>User</td>
      <td>“today’s target is 1,900ml” → <strong>so what</strong></td>
    </tr>
  </tbody>
</table>

<p>That apparent temperature nudged the goal from 1,800 to 1,900 is not a reason to open an app every day. <strong>The argument that gets you through review and the reason a person opens your app are different objects.</strong> New checklist item: <em>is this differentiator serving a regulation, or serving a user?</em> If it’s the regulation, you still owe a separate answer for the daily open.</p>

<p><strong>2. I burned traffic before instrumenting the funnel.</strong> Three events — onboarding completed, water logged, goal reached — never made it into the code. They sat marked incomplete in the status doc the whole time, and I spent all 5,201 impressions in that state.</p>

<table>
  <thead>
    <tr>
      <th>What I want to know</th>
      <th>Can I?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Did they drop off in the three-step region picker?</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Did they log water once and think “that’s it?”</td>
      <td>❌</td>
    </tr>
    <tr>
      <td>Did they log it and forget by the next day?</td>
      <td>❌</td>
    </tr>
  </tbody>
</table>

<p>Those three call for completely different fixes: shorten onboarding, redesign the value proposition, push the notification opt-in. <strong>I will now never know where those 22 people leaked out.</strong> An entire acquisition experiment, wasted. The rule I wrote down: instrument the funnel before you buy the traffic. Traffic without instrumentation isn’t data, it’s noise.</p>

<p><strong>3. I spent three weeks on a re-engagement engine with zero users.</strong> Personalized push took three weeks: mTLS client auth, D1, cron-based eligibility, send batching, fail-closed design, published legal documents. <strong>When it was done, one user had notifications turned on</strong> — again, circumstantially me.</p>

<p>Twenty-two people came into the app and none of them enabled notifications. The only hook capable of producing a return visit shipped connected to essentially nobody. This is the same pattern as my previous project, where I built multiplayer with no user base and no real match was ever possible. Only the shape changed, from multiplayer to a push engine. Three hours measuring opt-in conversion should have come before three weeks of engine.</p>

<p>There was a deadline on top of it. Apparent temperature and heat warnings moving the target <em>is</em> the app’s identity, and in September those variables disappear. I’d renamed it to something season-neutral, but the features were still pure summer — starting mid-July and shipping in August left a validation window structurally capped at about four weeks.</p>

<h3 id="one-thing-i-got-right-one-i-got-wrong">One thing I got right, one I got wrong</h3>

<p>The fail-closed design held up. “Never send to a user who hasn’t opted in” is enforced server-side, and I <strong>watched it work in live logs</strong> — there were moments with opt-in rows sitting in the database and zero send candidates, exactly as designed. Knowing the code says so and having seen it behave that way in production are different weights of knowledge.</p>

<p>A near-miss also went on the record. I built this project paired with a coding agent, and while it was waiting on my decision about adding a config value, it <strong>showed the command block first and put the question after it.</strong> I read that as already applied and ran the deploy. It happened to be a redeploy of identical code so nothing broke, but the intended feature never turned on. The lesson: if you’re going to hand someone a command to run, either apply the change first or state inside the block that it isn’t applied yet. A command block reads as “ready” all by itself — to people and agents alike.</p>

<h3 id="frozen-not-deleted">Frozen, not deleted</h3>

<p>I chose to freeze rather than take it down. The app stays live; only development stops.</p>

<p>Two reasons. Running cost is genuinely $0/month, so cost isn’t a reason to pull it. And the path I cut from review submission through to launch is itself an asset that shortens the next project by days. I catalogued 13 reusable pieces: the mTLS push server, the four-endpoint KMA proxy, the apparent-temperature calculation, the anonymous-key approach that identifies users without a login, three legal documents and the routes that serve them.</p>

<p>But <strong>one obligation survives the freeze.</strong> I actually published a privacy policy, and it contains a data-destruction clause. That promise holds whether or not the app comes down, so the deadline is pinned in the docs. A published notice doesn’t stop when development does.</p>

<h3 id="whats-left">What’s left</h3>

<p>Technically a success, a failure as a product. Same conclusion for the tenth time.</p>

<p>The difference is that this time <strong>real user data delivered it.</strong> The distribution channel was open, the policy risk was cleared, the external APIs were all wired up. Failing from <em>that</em> position means the remaining bottleneck isn’t the ability to build — it’s the step where you choose what to build.</p>

<p>Hydro Coach failed the question it should have failed at the start: <em>did this problem actually inconvenience me last week?</em> Not drinking water has never inconvenienced me. It doesn’t hurt right away, and by the time it matters I’m already thirsty. The friction the app removed was smaller than the friction of opening it.</p>

<p>The next project’s job isn’t to build better. It’s to actually pass that question.</p>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="build-in-public" />
      
        <category term="postmortem" />
      
        <category term="product" />
      
        <category term="apps-in-toss" />
      
        <category term="public-api" />
      

      
      
        <summary type="html"><![CDATA[심사를 통과해 출시까지 간 수분 코치를 접었다. 유입된 22명 중 다음 날 다시 연 사람은 0명이었다. 심사 방어 논리를 제품 가치로 착각했고, 계측 없이 유입을 태웠고, 사용자가 없을 때 재방문 엔진부터 만들었다.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">실기기 검증 포스트모템</title>
      <link href="https://sasostudio.dev/2026/07/27/it-broke-on-two-real-devices/" rel="alternate" type="text/html" title="실기기 검증 포스트모템" />
      <published>2026-07-27T11:00:00+09:00</published>
      <updated>2026-07-27T11:00:00+09:00</updated>
      <id>https://saso-studio.vercel.app/2026/07/27/it-broke-on-two-real-devices</id>
      
      
        <content type="html" xml:base="https://sasostudio.dev/2026/07/27/it-broke-on-two-real-devices/"><![CDATA[<p><em>🇬🇧 <a href="#english-version">English version below</a></em></p>

<p><a href="/2026/07/27/you-cant-report-your-own-win/">지난 글</a>에서 1대1 대전의 판정과 정산을 서버 쪽으로 옮긴 이야기를 썼다. 그렇게 해놓고 로컬에서 브라우저 탭 두 개를 띄워 붙여보면 잘 돌았다. 판정도 맞고, 매칭도 붙고, 정산도 됐다.</p>

<p>실기기 두 대에서 검증을 돌리자 하나씩 깨졌다. 결함 5건을 잡고 1건은 결함이 아님을 확인한 기록이다.</p>

<h2 id="큰-화면이-유리했다">큰 화면이 유리했다</h2>

<p>물리 세계가 화면 픽셀 위에 그대로 세워져 있었다. 캔버스 크기를 재서 그 자리에 벽을 세우고, 떨어지는 오브젝트는 고정된 픽셀 크기였다. 화면이 크면 벽 사이가 넓어지는데 오브젝트 크기는 그대로다. 데스크톱에서는 같은 보드에 세 배 넘게 담겼다.</p>

<p>합체 퍼즐에서 더 담긴다는 건 그냥 유리하다는 뜻이다. 화면 큰 사람이 이긴다.</p>

<p>급한 대로 보드 크기에 상한을 걸어 편차를 폰 범위로 좁혔다. 근본 해결은 좌표계를 떼어내는 것이었다. 물리는 고정된 논리 치수 위에서 돌고, 화면은 그 결과를 늘려서 보여주기만 한다. 창 크기를 바꿔도 물리적으로는 아무 일도 일어나지 않는다.</p>

<p>엔진은 싱글 모드와 공유하고 있어서 함부로 건드릴 수 없었다. 옵션을 하나 더하되 지정하지 않으면 기존 경로가 그대로 도는 방식으로 넣었다. 터치 좌표만 화면 비율에 맞춰 보정해주면 나머지는 그대로 맞아떨어졌다.</p>

<h2 id="같은-연출이-안-나왔다">같은 연출이 안 나왔다</h2>

<p>상대 보드에는 합체 파문이나 콤보 팝업 같은 연출이 나오지 않았다. 처음에는 축약해서 만들었는데, 내 화면과 상대 화면의 연출이 눈에 띄게 달라서 반려됐다.</p>

<p>원인을 찾다가 접근 자체가 틀렸다는 걸 알았다. 데이터를 더 보내면 되는 문제가 아니었다. 받는 쪽이 아예 다른 그리기 코드로 그리고 있었던 것이다.</p>

<p>그래서 봇전에서 쓰던 그리기 함수들을 공유 모듈로 옮겼다. 본문은 한 줄도 고치지 않고 위치만 바꿨다. 받는 쪽은 이벤트로 연출 상태를 재구성해서 같은 함수를 호출한다. 이제 픽셀 단위로 같고, 앞으로 연출을 손보면 양쪽이 자동으로 함께 바뀐다.</p>

<p>옮기는 김에 그리기 모듈이 빌드 도구 전용 문법에 묶여 있어 테스트에서 열리지 않던 문제도 걷어냈다. 순수한 계산 부분을 따로 떼어내는 쪽으로 풀었다.</p>

<h2 id="끊기면-누가-심판인가">끊기면 누가 심판인가</h2>

<p>가장 크게 깨진 지점이다.</p>

<p>한쪽 네트워크가 끊겼을 때를 처리하는 로직은 이미 있었다. 실기기 두 대로 시험하니 세 가지가 한꺼번에 드러났다. 끊긴 쪽 화면에는 “내가 끊겼다”와 “상대가 끊겼다”가 동시에 떠 있었다. 잠깐 끊겼다가 금방 돌아와도 양쪽 모두 패배로 처리됐다. 돌아오지 않은 사람 화면에는 자기가 이겼다고 떠 있었다.</p>

<p>뿌리는 둘이었다. 하나는 끊긴 기기에게도 판정 권한이 남아 있었다는 것. 다른 하나는 상대 신호가 얼마나 최근 것인지 재는 기준 시점이 양쪽에서 어긋나 있었다는 것이다. 그래서 돌아온 사람을 안 돌아온 사람으로 읽었다.</p>

<p>끊긴 쪽의 심판 자격을 아예 없앴다. 남아 있는 쪽만 판정한다. 신선도를 재는 기준도 절대 시각 하나로 통일했다. 그리고 한쪽이 끊기면 양쪽 보드를 함께 멈춘다. 돌아오면 카운트다운을 세고 재개하고, 끝내 안 돌아오면 그때 남은 사람이 결론을 낸다. 남은 사람이 결론을 낼 때도 서버에 먼저 자리를 잡은 뒤에 화면을 끝내도록 순서를 바꿨다.</p>

<p>나가기 버튼은 따로 다뤘다. 통신이 끊긴 것과 스스로 나간 것은 성격이 다르다. 나가기를 누르면 그 자리에서 자기 패배로 확정된다.</p>

<p>한 가지 더 정했다. 멈췄다가 다시 이어진 경기는 결과를 기록하지 않는다. 아직 안 끝난 경기니까.</p>

<h2 id="같은-기기인데-화면이-달랐다">같은 기기인데 화면이 달랐다</h2>

<p>같은 기기인데 화면이 딴판으로 보인다는 제보를 받고 한참 헤맸다.</p>

<p>범인은 시스템 글꼴 크기 설정이었다. 안드로이드 웹뷰는 사용자가 설정에서 글꼴을 키우면 그만큼 텍스트를 키워서 그린다. 그런데 내 레이아웃은 버튼 최소 크기와 비율 기반 높이를 섞어 쓰고 있어서, 글자가 커지는 순간 요소가 밀려 나가거나 잘렸다.</p>

<p>브라우저를 화면 없이 띄워 화면 크기와 글꼴 배율 조합을 만들어두고 픽셀로 재현했다. 실기기를 계속 붙잡고 있지 않아도 원인을 좁힐 수 있었다. 결국 앱에서는 시스템 배율을 무시하도록 고정했다. 어떤 배율에서도 같은 픽셀이 나온다.</p>

<h2 id="겹치지-않게가-아니라-겹칠-수-없게">겹치지 않게가 아니라, 겹칠 수 없게</h2>

<p>홈 화면 왼쪽 버튼들이 서로 겹쳤다. 각 요소에 절대 좌표를 줬는데 라벨 길이가 달라지면서 밀린 것이다.</p>

<p>좌표를 조정해서 안 겹치게 만들 수도 있었다. 그렇게 하지 않았다. 셋을 하나의 흐름 배치 안에 넣었다. 이제 라벨이 몇 줄이 되든 위아래로 밀릴 뿐, 겹치는 일 자체가 불가능하다.</p>

<p>높이도 고정값에서 최소값으로 바꿨다. 내용이 커지면 상자가 따라 커진다.</p>

<h2 id="고치지-않기로-한-것">고치지 않기로 한 것</h2>

<p>빌드 버전이 안 올라간 것 같다는 얘기가 나왔다.</p>

<p>설정 파일을 봤더니 이미 정확했다. 다른 곳에서 덮어쓰는지 전부 뒤졌지만 그런 곳은 없었다. 마지막으로 방금 만들어진 빌드 산출물을 열어 안에 박힌 버전 정보를 직접 꺼내봤다. 새 버전이 정확히 들어 있었다.</p>

<p>그래서 아무것도 고치지 않았다. 예전 파일이나 캐시된 화면을 봤을 가능성이 있다고 알렸다.</p>

<p>문제가 없는데 뭔가 고치는 쪽이 더 위험하다. 원인을 모르는 채로 손을 대면 그때부터 진짜 문제가 생긴다.</p>

<h2 id="배운-것">배운 것</h2>

<p>여섯 건을 한 표로 정리하면 이렇다.</p>

<table>
  <thead>
    <tr>
      <th>증상</th>
      <th>근본 원인</th>
      <th>조치</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>큰 화면이 유리</td>
      <td>물리 좌표계가 화면 픽셀에 결합</td>
      <td>논리 좌표계 분리, 화면은 스케일링만 담당</td>
    </tr>
    <tr>
      <td>상대 보드 연출 누락</td>
      <td>수신 측이 별도 렌더링 코드로 그림</td>
      <td>그리기 함수를 공유 모듈로 이동</td>
    </tr>
    <tr>
      <td>끊김 판정 오류 3종</td>
      <td>끊긴 기기에 판정 권한 잔존 + 신선도 기준 시각 불일치</td>
      <td>잔류 측 단독 판정, 절대 시각 통일, 동반 일시정지</td>
    </tr>
    <tr>
      <td>같은 기기, 다른 화면</td>
      <td>시스템 글꼴 배율이 WebView 텍스트를 확대</td>
      <td>앱에서 배율 고정</td>
    </tr>
    <tr>
      <td>홈 버튼 겹침</td>
      <td>절대 좌표 + 가변 라벨 길이</td>
      <td>플로우 배치 전환, 고정 높이 → 최소 높이</td>
    </tr>
    <tr>
      <td>버전 미반영 제보</td>
      <td>결함 아님 — 구 산출물/캐시 열람 추정</td>
      <td>산출물 직접 확인 후 수정 없이 원인 공유</td>
    </tr>
  </tbody>
</table>

<p>여섯 가지 모두 로컬에서는 초록불이었다. 개발용 탭 두 개로 돌리면 화면 크기가 같고, 네트워크가 안 끊기고, 글꼴 배율이 기본값이고, 라벨이 짧다.</p>

<p>실제 기기와 실제 사용자는 그 전제를 하나씩 무너뜨린다. 그래서 여섯 번 다 같은 자리로 돌아왔다. 조건을 맞춰서 문제가 안 나게 하는 것과, 조건이 어긋나도 문제가 생길 수 없게 만드는 것은 다르다. 앞의 것은 다음 기기에서 또 터진다.</p>

<hr />

<h2 id="english-version">English version</h2>

<p><a href="/2026/07/27/you-cant-report-your-own-win/">In the previous post</a> I wrote about moving 1v1 judgment and settlement onto the server. With that in place, two browser tabs on my laptop played beautifully. Verdicts agreed, matchmaking paired, trophies settled.</p>

<p>Then I turned on two real phones, and it came apart one piece at a time.</p>

<h3 id="the-bigger-screen-was-winning">The bigger screen was winning</h3>

<p>The physics world had been built directly on screen pixels. The canvas was measured, walls were placed at its edges, and falling objects were a fixed pixel size. A bigger screen meant wider walls with the same-size objects. On a desktop, more than three times as much fit on the same board.</p>

<p>In a merge puzzle, fitting more is simply an advantage. Whoever had the bigger screen won.</p>

<p>As a stopgap I capped the board size to pull the spread back into phone range. The real fix was detaching the coordinate system: physics now runs on fixed logical dimensions, and the screen only stretches the result for display. Resizing the window does nothing physical at all.</p>

<p>The engine is shared with single-player, so I couldn’t just rewrite it. I added one option that, when left unset, leaves the original path untouched. Correcting touch coordinates for the display ratio was the only other piece; everything else lined up on its own.</p>

<h3 id="the-effects-didnt-match">The effects didn’t match</h3>

<p>The opponent’s board had no merge ripples, no combo popups. I’d built a trimmed-down version at first, and it looked visibly different from my own side, so it got sent back.</p>

<p>Digging into it, I realized the whole approach was wrong. It wasn’t a matter of transmitting more data. The receiving side was drawing with entirely different code.</p>

<p>So I moved the drawing functions from bot matches into a shared module — relocated, not rewritten, without touching a line of the bodies. The receiving side now reconstructs the effect state from events and calls those same functions. It matches pixel for pixel, and any future change to the effects updates both sides automatically.</p>

<p>While I was in there, I also cleared up something that had been blocking tests: the drawing module was tied to build-tool-specific syntax and wouldn’t open outside the bundler. Splitting the pure computation into its own layer solved it properly.</p>

<h3 id="when-the-connection-drops-who-is-the-referee">When the connection drops, who is the referee?</h3>

<p>This is where it broke worst.</p>

<p>There was already logic for one side losing its connection. Testing on two real devices exposed three failures at once. The disconnected side’s screen showed “you dropped” and “your opponent dropped” simultaneously. A brief drop with a quick return still charged both players a loss. And the player who never came back saw a victory screen.</p>

<p>There were two roots. One was that the disconnected device still held judging authority. The other was that each side measured the freshness of the opponent’s last signal from a different reference point, so a player who had come back could read as one who never did.</p>

<p>I stripped that authority entirely: only the side that stayed connected judges. Freshness is now measured against one absolute clock. And when one side drops, both boards pause together — return, and a countdown resumes play; never return, and the remaining player finalizes. I also reordered that finalization so the remaining player claims the outcome on the server <em>before</em> ending the match locally.</p>

<p>Quitting is handled separately. Losing a connection and choosing to leave aren’t the same thing, so pressing quit resolves immediately as your own defeat.</p>

<p>One more rule: a match that paused and resumed doesn’t get a result recorded. It isn’t over yet.</p>

<h3 id="same-device-different-screen">Same device, different screen</h3>

<p>I got a report that the same device could render the UI completely differently, and it took a while to track down.</p>

<p>The culprit was the system font size setting. Android’s WebView scales text up when the user raises the font size in system settings. My layout mixed minimum button sizes with ratio-based heights, so the moment text grew, elements pushed out of place or got clipped.</p>

<p>I ran a headless browser across combinations of viewport size and font scale and reproduced it pixel by pixel, which meant I could narrow the cause without keeping a phone in my hand. In the end the app pins the system scale so the pixels are identical at any setting.</p>

<h3 id="not-dont-overlap--cant-overlap">Not “don’t overlap” — “can’t overlap”</h3>

<p>The buttons on the left side of the home screen were overlapping each other. Each element had absolute coordinates, and varying label lengths pushed them into one another.</p>

<p>I could have nudged the coordinates until they didn’t collide. I didn’t. I put all three inside a single flow container. Now, however many lines a label wraps to, they push each other down instead — overlapping isn’t possible.</p>

<p>Heights moved from fixed to minimum as well, so a box grows with its contents.</p>

<h3 id="the-thing-i-decided-not-to-fix">The thing I decided not to fix</h3>

<p>Someone raised that the build version didn’t look like it had gone up.</p>

<p>The config file was already correct. I checked everywhere that could override it and found nothing. Finally I opened the freshly built artifact and pulled the version information straight out of it. The new version was there, exactly as expected.</p>

<p>So I changed nothing, and passed along that an older artifact or a cached screen was the likely explanation.</p>

<p>Editing code that isn’t broken is the more dangerous move. Touch it without knowing the cause and that’s when you get a real problem.</p>

<h3 id="what-i-took-from-it">What I took from it</h3>

<p>The six cases, in one table:</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Root cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bigger screen wins</td>
      <td>Physics coupled to screen pixels</td>
      <td>Detached logical coordinates; screen only scales</td>
    </tr>
    <tr>
      <td>Opponent’s effects missing</td>
      <td>Receiving side drew with separate code</td>
      <td>Drawing functions moved into a shared module</td>
    </tr>
    <tr>
      <td>Three disconnect-verdict failures</td>
      <td>Judging authority left on the dropped device + mismatched freshness clocks</td>
      <td>Only the connected side judges; one absolute clock; paired pause</td>
    </tr>
    <tr>
      <td>Same device, different screen</td>
      <td>System font scale inflating WebView text</td>
      <td>Scale pinned in the app</td>
    </tr>
    <tr>
      <td>Overlapping home buttons</td>
      <td>Absolute coordinates + variable label lengths</td>
      <td>Flow layout; fixed heights → minimums</td>
    </tr>
    <tr>
      <td>“Version didn’t bump” report</td>
      <td>Not a defect — stale artifact/cache</td>
      <td>Verified the artifact directly; changed nothing, shared the cause</td>
    </tr>
  </tbody>
</table>

<p>All six were green locally. Two dev tabs mean identical screen sizes, no dropped connections, default font scaling, short labels.</p>

<p>Real devices and real users dismantle those assumptions one by one. And all six times I landed in the same place: arranging conditions so a problem doesn’t appear is not the same as making the problem impossible when conditions go wrong. The first kind comes back on the next device.</p>]]></content>
      

      
      
      
      
      

      <author>
          <name>CHPARK03</name>
        
        
      </author>

      
        
      

      
        <category term="game-dev" />
      
        <category term="multiplayer" />
      
        <category term="mobile" />
      
        <category term="build-in-public" />
      

      
      
        <summary type="html"><![CDATA[로컬 탭 두 개에서는 잘 돌던 1대1 대전이 실기기 두 대에서 깨졌다. 화면 크기 유불리, 연출 불일치, 끊김 판정, 시스템 글꼴 배율, 버튼 겹침을 잡고, 결함이 아니었던 1건은 고치지 않은 기록.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sasostudio.dev/assets/img/saso-logo.jpg" />
        <media:content medium="image" url="https://sasostudio.dev/assets/img/saso-logo.jpg" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
</feed>
