<?xml version='1.0' encoding='utf-8'?>
<rss version="2.0"><channel><title>개발 취재록</title><link>https://bug-casebook.pages.dev/</link><description>개발 문제의 원인과 해결을 취재합니다</description><language>ko</language><item><title>Windows 로컬 웹앱에서 WiFi QR 자동 입력값이 null로 나올 때</title><link>https://bug-casebook.pages.dev/posts/windows-wifi-qr-autofill-null/</link><guid>https://bug-casebook.pages.dev/posts/windows-wifi-qr-autofill-null/</guid><description>&lt;p&gt;무용 연습용 영상·대형 동기화 웹앱(formation)의 로컬 접속 화면을 다듬고 있었다. Windows PC의 Node 서버에서 현재 WiFi 이름과 비밀번호를 읽어 WiFi 접속용 QR의 입력값을 채우려 했지만, 첫 검증에서 &lt;code&gt;/wifi&lt;/code&gt; 응답은 &lt;code&gt;null&lt;/code&gt;이었다.   &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;이 사례에서 선택한 방법은 브라우저가 아니라 로컬 Node 서버에서 WiFi 정보를 읽는 것이었다.&lt;/strong&gt; &lt;code&gt;netsh wlan show interfaces&lt;/code&gt;로 현재 SSID(무선 네트워크 이름)를 읽고, &lt;code&gt;netsh wlan export profile ... key=clear&lt;/code&gt;로 내보낸 XML의 &lt;code&gt;&amp;lt;keyMaterial&amp;gt;&lt;/code&gt;에서 비밀번호를 얻어 &lt;code&gt;/wifi&lt;/code&gt;로 전달하는 구조다. 아래 명령의 &lt;code&gt;...&lt;/code&gt;는 생략 표시이며, 그대로 실행할 완성 명령이나 실제 구현 코드는 기록에 없다.   &lt;/p&gt;
&lt;p&gt;&lt;code&gt;null&lt;/code&gt;이 나오면 먼저 서버 PC가 WiFi에 연결돼 있는지 확인한다. 당시 확인 보고는 WiFi 연결이 끊겼고 PC가 유선을 사용하고 있다는 내용이었다. &lt;strong&gt;연결된 상태에서 SSID·비밀번호가 채워지는지는 확인 못 했다.&lt;/strong&gt; 연결이 없다는 설명은 가능하지만, 그것만으로 파싱 코드가 정상이라고 입증되지는 않는다.  &lt;/p&gt;
&lt;p&gt;또한 &lt;code&gt;/wifi&lt;/code&gt;에 평문 비밀번호를 담는 설계였다. 운영자 페이지만 호출한다는 설명은 있지만, 다른 기기의 직접 요청을 서버가 차단한다는 근거는 없다. 동작 검증과 함께 접근 제한도 별도로 확인해야 한다. &lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;문제의 대상은 Windows PC에서 실행하는 로컬 서버와 운영자 화면이었다. 운영자 화면은 WiFi 정보를 입력받아 브라우저 저장소인 &lt;code&gt;localStorage&lt;/code&gt;에 보관하고 QR을 만들었으며, 여기에 “현재 연결된 WiFi 정보를 기본값으로 쓰기”가 요청됐다. 변경 대상으로 보고된 파일은 &lt;code&gt;live/server.js&lt;/code&gt;와 &lt;code&gt;live/public/server.html&lt;/code&gt;이다.   &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;출발점은 자동 입력 기능 요청이었다. 서버를 띄워 검증한 단계에서 &lt;code&gt;/wifi&lt;/code&gt;가 &lt;code&gt;null&lt;/code&gt;을 반환했고, 이어진 확인에서는 WiFi가 끊긴 상태라고 보고됐다. 화면 렌더링과 “현재 연결된 WiFi 불러오기” 버튼 표시는 확인됐지만, 실제 WiFi 정보가 입력됐다는 보고는 없었다.    &lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;먼저 정보를 읽는 위치를 바꿔야 했다. 당시 설계 판단은 브라우저에서 WiFi 비밀번호를 직접 읽을 수 없으므로, PC에서 실행되는 Node 서버가 운영체제 명령을 호출하도록 한다는 것이었다. 이 구조가 읽으려는 대상은 서버 PC의 연결 정보다.  &lt;/p&gt;
&lt;p&gt;실제 &lt;code&gt;null&lt;/code&gt;에 대해서는 WiFi 미연결 상태가 설명으로 제시됐다. 다만 기록에는 &lt;code&gt;netsh&lt;/code&gt; 출력 전체나 파서 코드가 없고, &lt;code&gt;State: disconnected&lt;/code&gt;라는 확인 요약만 있다. 당시의 “파싱 로직 자체는 정상”이라는 결론은 연결된 상태의 시험 결과로 뒷받침되지 않으므로, 이 글에서는 정상 판정으로 받아들이지 않는다.  &lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;h3&gt;서버에서 읽고, 저장값이 없을 때 채운다&lt;/h3&gt;
&lt;p&gt;구현 설명에 나타난 흐름은 다음과 같다. 실제 코드를 재현한 예제가 아니라 당시 보고된 설계다.  &lt;/p&gt;
&lt;ol&gt;&lt;li&gt;서버가 &lt;code&gt;netsh wlan show interfaces&lt;/code&gt;로 현재 SSID를 읽는다. &lt;/li&gt;&lt;li&gt;프로필을 &lt;code&gt;key=clear&lt;/code&gt; 옵션으로 XML에 내보내고 &lt;code&gt;&amp;lt;keyMaterial&amp;gt;&lt;/code&gt;에서 비밀번호를 읽는다. 번역되는 출력 문구에 덜 의존하려는 선택이었다. 한국어·영문 환경에서 각각 검증한 결과는 없다.  &lt;/li&gt;&lt;li&gt;&lt;code&gt;/wifi&lt;/code&gt; 응답을 운영자 페이지에서 받아 저장값이 없을 때 자동 입력하고, 버튼으로 다시 불러오게 한다.  &lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;명령 자체의 조건도 구분해야 한다. Microsoft 문서는 &lt;code&gt;export profile&lt;/code&gt;의 &lt;code&gt;key=clear&lt;/code&gt;가 로컬 관리자 실행 조건에서 키를 평문으로 내보낸다고 설명한다. 이는 명령에 대한 외부 근거이며, 당시 서버의 실행 권한을 확인해 주지는 않는다. &lt;a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/netsh-wlan"&gt;Microsoft Learn: netsh wlan&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;평문이 어디까지 가는지 확인한다&lt;/h3&gt;
&lt;p&gt;이 설계에서 확인되는 노출 지점은 &lt;code&gt;/wifi&lt;/code&gt; 응답이다. 뷰어·리더 페이지는 호출하지 않는다고 했지만, 서버의 인증이나 접속 위치 제한은 설명되지 않았다. “버튼이 없는 페이지”와 “요청해도 비밀번호를 받을 수 없는 클라이언트”는 구분해서 검증해야 한다. &lt;/p&gt;
&lt;p&gt;XML 내보내기 뒤 파일을 어떻게 처리하는지도 기록에 없다. SSID와 프로필 이름이 다를 때의 처리, 명령 호출 방식, XML 파싱 방식, 응답 JSON 형태 역시 확인 못 했다. 따라서 이 기록만으로 복사해 쓸 서버 코드를 만들거나 평문 파일이 남지 않는다고 보증할 수는 없다.    &lt;/p&gt;
&lt;p&gt;후속 검증에서는 WiFi에 연결한 서버 PC에서 명령 결과와 &lt;code&gt;/wifi&lt;/code&gt; 응답, 폼 입력까지 이어서 확인할 필요가 있다. 다른 기기에서 직접 호출할 수 있는지와 내보낸 파일의 처리도 확인 대상이다. 현재 기록의 도달점은 미연결 상태의 &lt;code&gt;null&lt;/code&gt;과 화면 렌더링 확인까지다.    &lt;/p&gt;
&lt;p&gt;같은 화면에서는 WiFi QR과 뷰어 접속 QR이 가까워 카메라에 함께 들어오는 불편도 있었다. 카드 안에서 벌리는 수정 뒤에도 더 넓은 간격이 요구돼 &lt;code&gt;width:100vw&lt;/code&gt;와 &lt;code&gt;space-between&lt;/code&gt;으로 화면 양 끝에 배치했다고 보고됐다. 이후 실제 휴대폰 스캔이 개선됐다는 확인은 없으므로, 배치 변경까지만 확인된 곁가지다.     &lt;/p&gt;
&lt;h2&gt;취재 후기 — 값이 없다는 것과 코드가 맞다는 것&lt;/h2&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;무용 연습용 웹앱의 로컬 접속 화면을 다듬고 있어. WiFi 접속용 QR에 쓸 이름과 비밀번호를 매번 넣는 대신, 현재 연결에서 가져오려는 거야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;입력칸을 자동으로 채우려면 읽는 위치부터 정해야겠네. 브라우저에서 비밀번호를 직접 읽는 대신, PC에서 도는 Node 서버에 맡기는 구조야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;서버가 운영체제 명령으로 정보를 읽고 &lt;code&gt;/wifi&lt;/code&gt;라는 요청 경로로 돌려주게 하자. 우리가 가져오려는 건 서버 PC가 연결한 WiFi 정보야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 읽는 쪽과 입력하는 쪽을 나눠 확인해야겠네. 서버 응답에 값이 있는지부터 보고, 그다음 화면의 자동 입력을 확인하자. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;SSID, 즉 무선 네트워크 이름은 &lt;code&gt;netsh&lt;/code&gt;라는 Windows 네트워크 명령으로 읽는 설계야. 비밀번호도 서버 쪽에서 가져오게 돼 있어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;비밀번호는 XML이라는 구조화된 파일로 내보내서 &lt;code&gt;keyMaterial&lt;/code&gt; 항목을 읽기로 했어. 번역되는 출력 문구에 덜 의존하려는 선택이지. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다만 그 선택과 검증 결과는 구분하자. 한국어와 영문 환경에서 실제로 모두 통과했다는 시험 결과까지 확보한 건 아니야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;서버를 띄워 확인한 &lt;code&gt;/wifi&lt;/code&gt; 응답은 &lt;code&gt;null&lt;/code&gt;, 값이 없는 상태야. 아직 이름과 비밀번호가 돌아오는 장면은 확인하지 못했어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이 PC가 유선으로 연결돼서 현재 WiFi 정보가 없는 것 아닐까? 응답이 비었다는 것만으로 파싱 실패라고 정하기는 이르겠어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 &lt;code&gt;netsh&lt;/code&gt;의 실제 출력을 먼저 확인하자. WiFi 연결 자체가 있는지 알아야 값을 추출하는 단계까지 시험했는지 판단할 수 있어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;확인한 상태는 WiFi 연결 끊김이었고 PC는 유선을 사용하고 있었어. 현재 연결된 WiFi 이름이 없는 상태라는 설명과 맞아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;여기까지는 빈 응답을 설명할 수 있어. 하지만 WiFi를 연결한 뒤 값을 읽는 시험이 없으니, 파싱이 정상이라는 판정까지 내릴 수는 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;입력이 없는 경우를 확인했다고 입력이 있는 경우도 통과한 건 아니지. 값이 비어 있는 이유와 값 추출의 정확성은 각각 확인하자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;화면은 오류 없이 떴고 현재 WiFi를 불러오는 버튼도 보여. 다만 버튼이 보인다는 관찰을 실제 비밀번호가 채워졌다는 결과로 바꾸면 안 돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;자동 입력은 브라우저 저장소인 &lt;code&gt;localStorage&lt;/code&gt;에 저장값이 없을 때 하도록 했어. 이미 값이 있으면 자동으로 덮는 대신 버튼으로 다시 읽는 흐름이야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이제 노출 범위도 확인하자. &lt;code&gt;/wifi&lt;/code&gt; 응답에 평문 비밀번호가 들어가고 운영자 화면만 호출하는 구조지만, 직접 요청을 막는지는 아직 몰라. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;뷰어 화면에서 호출하지 않는다는 설명만으로 접근 제한까지 확인한 셈 칠 수는 없어. 서버가 요청을 거절하는지는 따로 검증해야 해. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;XML로 내보낸 뒤 파일을 어떻게 처리하는지도 확인 대상이네. 지금 확보한 설명에는 파일 정리나 실제 명령 호출 코드가 빠져 있어.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다음 확인은 WiFi에 연결한 PC에서 응답과 입력칸을 함께 보는 거야. 다른 기기의 직접 요청과 내보낸 파일 처리도 아직 확인할 항목으로 남겨 두자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번에 얻은 교훈은 확인한 조건만큼만 결론을 쓰자는 거야. 연결이 없을 때 빈 값이 나온 사실로, 연결됐을 때의 성공까지 보증하지는 말자.  &lt;/div&gt;&lt;/div&gt;</description></item><item><title>크롬은 되는데 웨일 브라우저에서만 스크립트가 안 돌 때 — Cannot destructure property of undefined</title><link>https://bug-casebook.pages.dev/posts/whale-blocks-fingerprint-js-filename/</link><guid>https://bug-casebook.pages.dev/posts/whale-blocks-fingerprint-js-filename/</guid><description>&lt;p&gt;각자 폰에 준비한 안무 영상을 리더의 조작에 맞춰 재생하는 무용 연습용 영상 동기화 웹앱을 만들고 있었다. 노트북에 서버를 매번 띄우는 번거로움을 줄이려고 동기화 신호를 클라우드로 옮긴 뒤, 안드로이드 웨일에서 영상을 골라도 방 만들기와 참여 버튼이 켜지지 않았다. 같은 폰의 일반 크롬에서는 동작했다.     &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;이번 웨일(안드로이드) 장애는 &lt;code&gt;fingerprint.js&lt;/code&gt;를 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꾸고 두 페이지의 참조를 수정한 뒤 해결됐다.&lt;/strong&gt; 배포 후 정상 동작했다는 응답이 있었다.   &lt;/p&gt;
&lt;p&gt;직접 확인된 오류는 페이지 진입 직후 &lt;code&gt;window.FormationFingerprint&lt;/code&gt;가 &lt;code&gt;undefined&lt;/code&gt;여서 &lt;code&gt;fingerprintVideo&lt;/code&gt;를 구조분해하는 코드가 중단된 것이다. 당시 분석은 이 초기화 실패 때문에 파일 이벤트 처리와 WebSocket 연결 등 뒤의 코드가 실행되지 않았다는 것이었다.  &lt;/p&gt;
&lt;p&gt;아래는 수정 방향을 보여주는 예시다. 기록에 태그 변경 내역 전체가 남아 있지 않아 실제 코드를 그대로 옮긴 것은 아니다.  &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- 변경 전 --&amp;gt;
&amp;lt;script src=&amp;quot;fingerprint.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;!-- 변경 후 --&amp;gt;
&amp;lt;script src=&amp;quot;mediacheck.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;파일명 변경 후 해결됐다는 관찰과, 추적 방지가 파일명을 차단했다는 설명은 구분해야 한다.&lt;/strong&gt; 추적 방지 설명은 당시 분석에서 제시한 추론이다. 어떤 기능이나 필터가 차단했는지 보여주는 기록은 없다. 변경 후 데스크톱에서는 &lt;code&gt;FormationFingerprint&lt;/code&gt; 객체와 함수가 확인됐다.   &lt;/p&gt;
&lt;p&gt;같은 오류를 조사할 때는 파일 선택 처리보다 먼저 페이지 최초 오류와 전역 객체 생성 여부를 확인하자. 이번에는 &lt;code&gt;?debug=1&lt;/code&gt; 화면 패널에 표시된 페이지 진입 오류가 진단의 전환점이었다.  &lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;리더가 연습방을 만들고 영상을 선택하면, 참여자는 방 목록에서 자기 방에 들어가 각자 준비한 영상 파일을 고르는 흐름이다. 영상은 별도로 공유하고, 앱은 리더 조작에 맞춰 재생하도록 동기화 신호를 전달하는 구조로 설계했다.   &lt;/p&gt;
&lt;p&gt;서로 같은 영상을 골랐는지 확인하기 위해 파일 일부로 계산한 식별값인 ‘영상 지문’을 대조한다. 이를 계산하는 공통 스크립트가 &lt;code&gt;fingerprint.js&lt;/code&gt;였고, 페이지는 이 스크립트가 준비하는 &lt;code&gt;window.FormationFingerprint&lt;/code&gt; 객체의 함수를 사용했다. 영상 선택 뒤 버튼이 켜지지 않는 문제를 쫓다가, 그보다 앞선 공통 스크립트 초기화 실패를 발견한 사건이다.     &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;안드로이드에서 영상 파일명은 보였지만 방 만들기와 참여 버튼이 활성화되지 않았다. 뷰어는 방 정보를 불러오는 상태에 머물렀고, 아이폰과 데스크톱은 됐다. 이후 같은 안드로이드 폰에서도 일반 크롬은 되고 웨일은 안 된다는 비교가 나왔다.   &lt;/p&gt;
&lt;p&gt;8월 25일의 첫 제보 이후 8월 30일에도 진행할 수 없다는 보고가 이어졌다. 화면 디버그 패널을 붙인 뒤 받은 로그의 핵심은 다음과 같다. 환경은 Android 10의 Whale 3.9.14.9(Chrome 138 기반)였다. 오류 뒤에 붙어 있던 배포 주소는 생략했다.    &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;secureContext: true | crypto.subtle: true | Blob.arrayBuffer: true | File.text: true
❌ window.error: Uncaught TypeError: Cannot destructure property &amp;#x27;fingerprintVideo&amp;#x27; of &amp;#x27;window.FormationFingerprint&amp;#x27; as it is undefined.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;로그가 확인해 주는 것은 나열한 API의 지원 여부와 구조분해 시점의 객체 부재다. 지원 표시만으로 암호화나 파일 읽기 호출까지 정상이라고 판정할 수는 없다. 또한 객체가 없다는 사실만으로 다운로드 차단과 실행 실패를 구별할 수도 없다. &lt;/p&gt;
&lt;p&gt;당시에는 &lt;code&gt;.html&lt;/code&gt; 없는 &lt;code&gt;/leader&lt;/code&gt;에서 상대경로가 잘못됐을 가능성도 의심했다. 하지만 데스크톱에서 같은 경로로 열었을 때 객체가 정상이라는 확인이 나와, 그 경로만으로 웨일의 실패를 설명하기는 어려워졌다. 이후 파일명 차단 가설에 따라 이름을 바꿨고 정상 동작 확인을 받았다. 차단 기능 자체를 특정한 실험 결과는 남아 있지 않다.   &lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;fingerprint.js&lt;/code&gt;를 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꾸고 두 페이지의 참조를 수정해 배포했다. 파일 헤더 주석에도 이름을 바꾼 이유를 기록했다. 데스크톱에서는 새 파일 로드와 기존 &lt;code&gt;FormationFingerprint&lt;/code&gt; 객체, 함수 존재를 확인했다.    &lt;/p&gt;
&lt;p&gt;이어 “된다!! 됐어! 잘했다!”라는 응답과 파일 선택 필터를 다시 넣어 달라는 요청이 왔다. 마지막에는 확장자 필터 복원과 배포가 보고됐지만, 복원한 필터가 웨일에서 원하는 화면을 띄우는지에 대한 후속 확인은 없다. 파일명 변경의 성공과 필터 복원의 검증 상태는 별개다.  &lt;/p&gt;
&lt;p&gt;재발 방지 설계에서는 필수 객체가 없을 때 명확한 오류를 표시하고, 그 객체에 의존하는 버튼과 처리를 중단하는 편이 낫다. 단순히 함수를 &lt;code&gt;null&lt;/code&gt;로 두고 초기화를 계속하는 예제는 이후 호출부까지 보호하지 못하므로 여기서는 제시하지 않는다. 이는 이번에 적용한 내역이 아니라 추가 설계 제안이다.&lt;/p&gt;
&lt;h2&gt;취재 후기 — 파일을 고르기 전에 이미 멈춰 있었다&lt;/h2&gt;
&lt;p&gt;기록에 남은 가설과 관찰을 네 사람이 직접 디버깅하는 장면으로 재구성했다.&lt;/p&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;각자 폰에 둔 안무 영상을 리더 조작에 맞춰 보는 웹앱을 만들고 있잖아. 내 안드로이드에서는 파일명까지 보이는데 방 만들기와 참여 버튼이 안 켜져.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;영상 길이를 읽는 &lt;code&gt;readDuration&lt;/code&gt;이 기다리는 건 아닐까? &lt;code&gt;loadedmetadata&lt;/code&gt;, 즉 영상의 기본 정보가 준비됐다는 신호가 안 오면 다음 단계로 못 가잖아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 기다리는 시간을 제한하고, 길이를 못 읽어도 진행하게 바꿔 보자. 수정한 버전을 문제 기기에서 열어 버튼이 켜지는지 확인하면 돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다시 해도 참여 버튼이 안 켜져. 아이폰과 데스크톱은 되는데, 내 안드로이드에서는 방 정보를 불러오는 중이라는 문구가 계속 남아 있어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 길이 읽기 수정으로 이 장애를 해결했다고 판정할 수 없어. 기다림을 제한했는데도 문제 기기의 증상이 그대로 남았으니 다음 근거가 필요해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;멈출 수 있는 코드를 보강한 것과 실제 멈춘 원인을 찾은 건 구별해야 해. 다른 환경의 성공만으로 문제 기기까지 고쳤다고 결론 내리면 안 돼.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;방 정보가 안 오니 WebSocket 문제일 수도 있겠어. 서버와 연결을 유지하며 정보를 주고받는 통신인데, 이 연결이 안 되면 방 정보를 못 받잖아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;먼저 배포된 뷰어, 그러니까 참여자 화면에서 방 정보를 받는지 확인하자. 연결 상태도 화면에 표시하고, 폰에서는 브라우저를 바꿔 비교해 보자.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;데스크톱 뷰어는 연결됐고 방 정보도 받았어. 폰에서도 다시 비교했는데 일반 크롬은 잘 돼. 지금 실패하는 건 내가 쓰는 웨일이야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;조사 범위는 웨일로 좁혀졌어. 다만 다른 브라우저에서 연결됐다는 결과만으로 웨일의 통신이 어디에서 멈췄는지까지 판정할 수는 없어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;방 정보를 못 받았다는 건 결과야. 연결을 시도했다가 실패한 건지, 연결을 시작하는 코드까지 가지 못한 건지 구분해야 원인을 좁힐 수 있어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;웨일에서만 실패한다면 광고나 추적을 막는 기능이 통신을 차단하는 건 아닐까? 브라우저별 차이가 있으니 그 설정도 의심해 볼 만해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;연결 상태 표시를 보고 차단 설정을 바꿔 재시도하면 그 가설을 시험할 수 있어. 하지만 설정 변경 전후의 결과는 아직 없으니 통신 차단으로 확정하지 말자. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;지금 새로 눈에 띄는 건 파일 선택 화면이야. 구글 포토가 열리는 것 같고, 기본 설정을 지워도 클라우드 미디어에 접근한다는 안내가 나와.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 관찰은 파일 선택 화면에 관한 거라 통신 차단을 입증하지는 못해. 새로운 단서로 넘어가더라도 앞의 가설은 미확인으로 남겨 두자.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;파일 형식을 제한하는 &lt;code&gt;accept&lt;/code&gt; 값 때문일까? 모든 영상 형식을 뜻하는 &lt;code&gt;video/*&lt;/code&gt;가 사진·영상 선택 화면을 부르는 게 아닌지 의심돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 값을 구체적인 형식으로 바꾸고, 다음에는 확장자만 남겨 비교하자. 선택 화면이 달라지는지와 선택 뒤 진행되는지를 나눠서 봐야 해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;형식을 바꿔도 마찬가지였고 확장자만 남겨도 안 됐어. 필터를 아예 뺀 뒤에도, 닷새 뒤 다시 해 보니 파일 선택 후 재생할 상태로 넘어가지 못해.    &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;필터 수정으로 전체 문제가 해결되지는 않았어. 다만 ‘안 된다’는 결과만으로 매번 선택 화면과 버튼 중 무엇이 그대로였는지까지 단정하지는 말자.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;파일을 고르는 화면의 불편과 고른 뒤 처리가 멈추는 문제는 따로 확인해야 해. 눈에 보이는 화면을 여러 번 바꿨다고 원인에 가까워진 건 아니야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이제 오류를 화면에 보여주는 디버그 패널을 붙이자. &lt;code&gt;?debug=1&lt;/code&gt;로 열면 파일 선택과 길이 읽기, 영상 구별용 식별값인 지문 계산 과정을 볼 수 있어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;파일을 고르기도 전에 오류가 떠. &lt;code&gt;window.FormationFingerprint&lt;/code&gt;라는 공용 객체가 없는 상태인 &lt;code&gt;undefined&lt;/code&gt;라서, 안에 있어야 할 함수를 꺼내지 못한대. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 객체에는 영상 지문 계산 함수 &lt;code&gt;fingerprintVideo&lt;/code&gt;가 있어야 해. 객체에서 값을 꺼내는 구조분해가 먼저 실패해서, 뒤의 파일 처리와 통신도 시작 못 했다는 설명이 맞아떨어져.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;주소가 &lt;code&gt;.html&lt;/code&gt; 없는 &lt;code&gt;/leader&lt;/code&gt;여서 스크립트의 상대경로가 달라진 건 아닐까? 현재 페이지를 기준으로 파일 위치를 찾다가 다른 곳을 보는지 확인해야겠어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 데스크톱에서도 정확히 &lt;code&gt;/leader&lt;/code&gt;로 열어 객체가 있는지 확인하자. 같은 경로에서도 정상이라면 주소 모양만으로 웨일의 실패를 설명하기 어려워져.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;데스크톱에서는 같은 &lt;code&gt;/leader&lt;/code&gt;에서도 객체가 정상으로 잡혀. 내 웨일에서는 페이지에 들어오자마자 객체가 없다는 오류가 나니까 차이가 남아 있어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;경로만으로는 이 차이가 설명되지 않아. 파일명 차단 가설을 시험하려면 &lt;code&gt;fingerprint.js&lt;/code&gt;를 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꾸고 두 페이지의 참조도 바꿔 보자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;주소가 낯설게 보인다는 이유만으로 원인이라고 할 수는 없어. 같은 조건으로 비교해서 설명하지 못한 차이를 남겨야 다음 실험도 의미가 있어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이름을 바꾼 버전으로는 이제 돼! 데스크톱에서도 새 파일이 로드되고 기존 객체와 함수가 확인됐어. 다음에는 파일 선택 필터를 다시 넣어야겠어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;해결 확인은 이름을 바꾼 뒤 동작했다는 데까지야. 어떤 추적 방지 필터가 막았는지는 확인하지 못했고, 뒤에 복원한 파일 선택 필터도 실기기 결과를 더 확인해야 해.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;여러 기능이 함께 멈추면 각각을 고치기 전에 최초 오류부터 확인하자. 효과가 확인된 해결 조치와 아직 검증하지 못한 원인 설명도 따로 남겨야 해.&lt;/div&gt;&lt;/div&gt;</description></item><item><title>여러 폰에서 같은 영상 동시 재생, 영상 서버 없이 만들기 — 신호만 중계하고 각자 파일은 부분 해시로 확인</title><link>https://bug-casebook.pages.dev/posts/sync-video-multiple-phones-without-video-server/</link><guid>https://bug-casebook.pages.dev/posts/sync-video-multiple-phones-without-video-server/</guid><description>&lt;p&gt;&lt;strong&gt;영상은 각자 폰에 미리 저장하고, 서버는 재생 위치와 상태만 중계하면 된다.&lt;/strong&gt; 여러 사람이 각자 폰으로 같은 안무 영상을 따라 보고 대형·동선을 겹쳐 보는 웹앱을 이렇게 옮겼다. 목표는 연습할 때마다 노트북 서버를 켜고 같은 와이파이에 연결한 뒤 바뀐 IP를 알려 주는 수고를 없애는 것이었다   .&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;리더와 뷰어 모두 미리 받은 파일을 선택해 &lt;code&gt;URL.createObjectURL(file)&lt;/code&gt;로 재생한다. 클라우드 서버는 재생·정지·재생 위치 신호와 선택적인 동선 데이터만 전달한다. 파일 대조에는 &lt;strong&gt;파일 크기 + 앞·중간·끝 각 8MB의 부분 해시&lt;/strong&gt;를 사용했다   .&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;영상 바이트를 이 서버로 전송하지 않으므로 영상 중계 트래픽을 없앤다. 같은 와이파이와 노트북 서버도 필요 없어진다. 파일 사전 공유는 별도로 해야 한다  .&lt;/li&gt;&lt;li&gt;부분 해시는 전체 파일을 한꺼번에 메모리에 읽는 부담을 줄이려는 선택이다. 같은 크기의 두 파일이 읽지 않은 구간에서만 다르면 구별할 수 없으므로, 일치는 파일 전체의 동일성을 보장하지 않는다. 세션에서는 같은 파일을 사전 공유하는 상황에 쓸 실용적인 검사로 판단했다  .&lt;/li&gt;&lt;li&gt;폰 테스트는 HTTPS에서 진행한다. 세션에서도 일반 HTTP의 PC IP로 접속하면 &lt;code&gt;crypto.subtle&lt;/code&gt;을 사용할 수 없다는 제약을 확인했다  . 이 API의 보안 컨텍스트 요구와 비스트리밍 입력 제약은 &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest"&gt;MDN의 digest 문서&lt;/a&gt;에서도 확인할 수 있다.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;아래는 설계 설명용 예시다. &lt;strong&gt;저장소 구현을 복사한 코드가 아니다.&lt;/strong&gt; 앞·중간·끝 8MB를 읽는 방식은 세션에 있지만, 아래의 작은 파일 처리 기준·크기 직렬화·중간 오프셋·SHA-256 선택은 이 예시의 결정이다  .&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const CHUNK = 8 * 1024 * 1024; // 정확히는 8 MiB

async function partialFingerprint(file) {
  const size = file.size;
  const offsets = size &amp;lt;= CHUNK * 3
    ? [0]
    : [0, Math.floor(size / 2 - CHUNK / 2), size - CHUNK];
  const parts = [new TextEncoder().encode(String(size))];
  for (const off of offsets) {
    const end = size &amp;lt;= CHUNK * 3 ? size : off + CHUNK;
    parts.push(new Uint8Array(await file.slice(off, end).arrayBuffer()));
  }
  const joined = new Blob(parts);
  const digest = await crypto.subtle.digest(&amp;#x27;SHA-256&amp;#x27;, await joined.arrayBuffer());
  return [...new Uint8Array(digest)]
    .map(b =&amp;gt; b.toString(16).padStart(2, &amp;#x27;0&amp;#x27;)).join(&amp;#x27;&amp;#x27;);
}

// 리더와 뷰어가 동일한 알고리즘으로 계산한 결과를 비교한다.
// 불일치하면 참여를 막는다. 일치해도 파일 전체가 같다는 증명은 아니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 예시는 영상 바이트를 최대 24MiB 읽는다. 조각과 합친 버퍼가 함께 존재할 수 있으므로 &lt;strong&gt;최대 메모리 사용량이 24MiB라는 뜻은 아니다.&lt;/strong&gt; 실제 앱과 연동하려면 양쪽의 지문 알고리즘을 똑같이 맞춰야 한다.&lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;이 웹앱에서 리더는 재생·정지·재생 위치를 조작하고, 참가자인 뷰어들은 자기 폰의 영상을 그 상태에 맞춘다. 동선 파일을 붙이면 영상 위에 대형도 표시한다. 기존에는 노트북 서버가 영상과 SSE 신호를 함께 제공했다. SSE는 서버가 브라우저에 이벤트를 계속 보내는 통신 방식이다   .&lt;/p&gt;
&lt;p&gt;클라우드판은 다음 구조로 설계했다. DO(Durable Object)는 이 설계에서 방별 상태와 연결을 관리하는 단위다 .&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[리더 / 뷰어 브라우저] ─ 영상은 각자 로컬 파일
          │ WebSocket: 재생 상태·동선·마크
          ▼
[Cloudflare Worker] ─ 페이지 제공 + 방 개설·목록 API
   ├─ Lobby DO 하나 : 활성 방 목록
   └─ 방별 Room DO  : 방 상태 저장 + 연결된 참가자에게 전달
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;리더는 방 이름과 영상을 선택한다. 동선 파일 &lt;code&gt;formation.json&lt;/code&gt;은 선택 사항이며, 붙이면 서버를 거쳐 뷰어에게 전달된다. 뷰어는 동선 파일을 따로 선택할 필요가 없다   .&lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;문제는 반복되는 준비 작업이었다. 연습할 때마다 노트북 서버를 켜고, 모두 같은 와이파이에 접속하고, 서버 IP를 알려 줘야 했다 .&lt;/p&gt;
&lt;p&gt;초기 논의는 클라우드 영상 전송 비용으로 흘렀다. 720p·2Mbps·194초 영상을 15명이 여러 차례 재생하는 조건에서 월 100GB가 넘는 전송량을 예상하고 저장소나 VM을 검토했다. 이는 세션 당시의 가정에 따른 계산이며 실측 사용량이나 청구액이 아니다 .&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;해결해야 할 운영상의 제약은 영상 제공과 재생 신호 중계가 노트북 서버에 함께 묶여 있다는 점이었다. 전환점은 “영상을 스트리밍 하거나 다운로드하게 하진 않아도 돼”라는 요구 정리였다. 영상은 카톡 등으로 사전 공유하고, 공개된 고정 주소의 서버는 동기화만 담당하면 됐다  .&lt;/p&gt;
&lt;p&gt;영상 전송을 빼면 중계할 데이터가 줄어든다. 다만 이를 곧바로 서버 전체 비용이 0이라는 보장으로 읽으면 안 된다. Durable Objects에는 요청·실행 시간·저장소 사용 등에 따른 과금 기준이 있다. 이 글에서 확인된 것은 영상 중계를 제거한 설계이며, 장기 운영 비용 측정은 아니다. &lt;a href="https://developers.cloudflare.com/durable-objects/platform/pricing/"&gt;Cloudflare 요금 문서&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;새로 생기는 문제는 파일 버전이다. 각자 다른 인코딩·길이·시작점의 영상을 준비하면 같은 재생 위치 신호를 받아도 다른 장면을 보게 된다. 그래서 선택한 파일을 대조하는 절차를 넣었다 .&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;h3&gt;1. 영상은 선택한 로컬 파일로 재생&lt;/h3&gt;
&lt;p&gt;리더와 뷰어 모두 &lt;code&gt;&amp;lt;input type=file&amp;gt;&lt;/code&gt;로 파일을 선택하고 &lt;code&gt;URL.createObjectURL(file)&lt;/code&gt;로 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;에 연결한다. 세션에서는 과거에 서버 영상을 통째로 Blob으로 만들다가 겪은 iOS 메모리 문제와 구분하면서도, 로컬 File 재생은 실기기에서 먼저 확인해야 할 설계의 핵심 위험으로 다뤘다  .&lt;/p&gt;
&lt;p&gt;데스크톱 자동 검증에서는 재생 준비 상태 &lt;code&gt;readyState=4&lt;/code&gt;를 확인했다. 이후 “아이폰도 되고.. 데스크탑도 되는데”라는 실제 사용 확인이 나왔다. 이 기록만으로 긴 영상의 메모리 사용량이나 모든 모바일 브라우저의 동작까지 검증됐다고 할 수는 없다  .&lt;/p&gt;
&lt;h3&gt;2. 파일 대조는 부분 해시로&lt;/h3&gt;
&lt;p&gt;세션의 구현 보고는 앞·중간·끝 각 8MB를 읽는 부분 해시다. 리더가 방을 만들 때 지문을 등록하고, 뷰어 파일의 지문과 다르면 안내 후 참여를 막도록 설계했다  .&lt;/p&gt;
&lt;p&gt;자동 검증에서는 두 탭에 동일한 영상 바이트를 전달했다. 지문 일치와 참여를 확인한 뒤, 새로 불러온 뷰어가 리더를 따라 &lt;code&gt;0.40→0.82→1.23초&lt;/code&gt;로 재생되는 것을 확인했다. 다른 파일의 참여 차단은 수동 테스트 항목으로 남겼으며, 이 세션에는 그 테스트 결과가 명시돼 있지 않다   .&lt;/p&gt;
&lt;h3&gt;3. 렌더링과 동기화 알고리즘을 재사용&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;core.js&lt;/code&gt;와 &lt;code&gt;seekComp&lt;/code&gt;, &lt;code&gt;RESYNC_THRESHOLD&lt;/code&gt;를 포함한 기존 동기화 로직을 재사용하고, SSE·HTTP 요청 중심의 통신을 WebSocket으로 옮겼다. 기존 메시지 유형 &lt;code&gt;sync&lt;/code&gt;, &lt;code&gt;formation&lt;/code&gt;, &lt;code&gt;mark&lt;/code&gt;, &lt;code&gt;reload&lt;/code&gt;도 유지하는 방향이었다   .&lt;/p&gt;
&lt;p&gt;이는 모든 서버리스 환경에서 SSE를 쓸 수 없다는 뜻은 아니다. 선택한 Cloudflare 구조에서는 WebSocket Hibernation으로 연결을 유지하면서 유휴 시 실행 비용을 줄이는 방식을 사용했다 . 해당 특성은 &lt;a href="https://developers.cloudflare.com/durable-objects/best-practices/websockets/"&gt;Cloudflare WebSocket 문서&lt;/a&gt;에 설명돼 있다.&lt;/p&gt;
&lt;p&gt;영상이 로컬 파일이 되면서 클라우드판에는 뷰어 대역폭 절약을 위한 HLS 화질 단계와 ffmpeg 인코딩을 가져올 필요가 없어졌다 .&lt;/p&gt;
&lt;h3&gt;4. 별도 DB 대신 DO 내장 저장소&lt;/h3&gt;
&lt;p&gt;“외부 DB가 필요 없다”와 “저장할 상태가 없다”는 다르다. Cloudflare 설계에서는 Lobby DO가 방 목록을, Room DO가 방 메타데이터·동선·마크 등을 관리한다. 내장 저장소를 쓰므로 별도의 DB 서비스를 두지 않는 선택이었다  .&lt;/p&gt;
&lt;p&gt;리더의 조작이 1시간 없으면 방을 정리하는 요구도 구현 항목에 들어갔다. 다만 당시 구현 보고는 실제 1시간 경과 후 alarm 발화를 배포 뒤 확인할 항목으로 구분했다. 이 세션만으로 장시간 만료 동작의 실측까지 완료됐다고 쓰지는 않는다  .&lt;/p&gt;
&lt;h2&gt;덤 — 파일 선택 실패처럼 보였던 스크립트 초기화 오류&lt;/h2&gt;
&lt;p&gt;웨일에서 파일 선택 후 진행하지 못하는 문제는 HTTPS 배포 후에도 남았다. 실제 로그에서는 &lt;code&gt;crypto.subtle&lt;/code&gt;이 존재했지만 &lt;code&gt;window.FormationFingerprint&lt;/code&gt;가 &lt;code&gt;undefined&lt;/code&gt;여서 페이지 초기화 중 예외가 났다 .&lt;/p&gt;
&lt;p&gt;세션에서는 &lt;code&gt;fingerprint.js&lt;/code&gt;라는 파일명이 추적 차단에 걸렸다고 판단해 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꿨고, 이후 “된다!! 됐어!”라는 확인을 받았다. 로그와 이름 변경 후 성공은 확인됐지만, 차단 규칙 자체를 추출해 검증한 기록은 없다. 이 사례를 모든 브라우저에서 특정 파일명을 금지해야 한다는 규칙으로 일반화하지는 않는다  .&lt;/p&gt;
&lt;h2&gt;취재 후기 — 영상을 어디로 보낼지가 아니라, 보내야 하는지부터&lt;/h2&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;각자 폰으로 같은 안무 영상을 맞춰 보는 웹앱을 만들고 있어. 그런데 연습할 때마다 노트북 서버를 켜고, 모두 같은 와이파이에 붙인 뒤 IP를 알려 주는 게 번거로워 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;서버를 클라우드로 옮기면 편해지겠네. 다만 영상을 계속 내려주면 전송량이 커져. 15명이 반복해서 연습하는 조건으로 월 100GB 넘게 계산했어. 실제 사용량은 아니고 가정에 따른 추정이야 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 계산은 서버가 영상까지 전달할 때의 이야기지. 우리가 없애려는 준비 과정에서, 영상 배포도 꼭 이 서버가 해야 하는지 먼저 확인하자.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;영상은 카톡으로 미리 주고받으면 돼. 이 앱에서 다운로드나 스트리밍까지 할 필요는 없어. 서버를 매번 띄우고 주소를 알려 주는 과정을 없애고 싶은 거야  .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 영상은 각자 폰에 두고, 서버는 재생 위치와 상태만 전달하면 되겠네. 같은 와이파이에 모일 필요도 없고 노트북 서버도 필요 없어져 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;싸게 전송할 곳을 찾기 전에, 우리가 직접 전송해야 하는 데이터부터 가려야겠네. 준비의 번거로움을 줄이는 요구가 구조를 바꾼 셈이야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그런데 각자 영상을 준비하면 다른 버전을 가져올 수도 있어. 재생 위치가 같아도 장면이 달라질 테니 파일 내용으로 계산한 지문, 즉 해시를 비교하자  .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;전체 파일을 한 번에 읽는 방식은 메모리부터 확인해야 해. 여기서 쓸 &lt;code&gt;crypto.subtle.digest&lt;/code&gt;는 입력을 통째로 받아서, 수백 MB 파일을 그대로 넣는 방식을 피하려고 했어 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그래서 앞·중간·끝 8MB씩 읽는 부분 해시를 구현했어. 전체 영상을 읽는 대신 일부만 읽고 파일 크기도 대조에 넣는 설계야  .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;읽는 양을 줄이는 선택이네. 하지만 읽지 않은 부분만 달라진 같은 크기의 파일은 놓칠 수 있어. 같은 파일을 사전 공유한다는 전제 아래 쓸 검사로 봐야 해 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;지문이 다르면 막을 수 있지만, 같다고 모든 바이트를 검사한 건 아니야. 검사 범위를 줄였으면 그 한계도 같이 설명해야 해.&lt;/div&gt;&lt;/div&gt;
&lt;blockquote&gt;*리더와 뷰어를 브라우저 두 탭에서 시험했다.*&lt;/blockquote&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;선택한 파일을 임시 주소로 연결하는 &lt;code&gt;createObjectURL&lt;/code&gt;로 리더 영상이 재생돼. 그런데 같은 파일로 들어온 뷰어는 지문과 참여가 정상인데도 0초에서 멈춰 있어  .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;뷰어의 메시지 처리 중 예외가 났을 수도 있어. 처리 코드가 예외를 밖으로 드러내지 않는 구조라, 오류가 보여야 할 곳에서 조용할 가능성이 있어 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;우선 서버가 보내는지부터 확인하자. WebSocket은 연결을 유지하며 양방향 메시지를 주고받는 통로야. 페이지 처리 코드를 거치지 않는 연결로 신호가 오는지 확인하자 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;직접 붙인 연결에는 신호가 와. 뷰어 페이지를 새로 불러오니 이번에는 재생 위치가 0.40, 0.82, 1.23초로 움직이며 리더를 따라가  .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번 시험에서는 새로 불러온 상태로 성공했네. 앞선 멈춤은 1.39초짜리 영상이 끝난 뒤 합류한 상태와 시험 중 상태 조작이 겹친 것으로 판단했어. 예외 가설은 확인되지 않았어 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;재시도 성공은 관찰이고, 앞선 실패 원인은 그 관찰을 설명하는 판단이야. 둘을 구분해야 한 번의 성공으로 모든 동기화 문제가 없다고 결론 내리지 않겠네.&lt;/div&gt;&lt;/div&gt;
&lt;blockquote&gt;*배포 환경을 확인하고 실제 폰에서도 시험했다.*&lt;/blockquote&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;폰에서는 HTTPS 주소로 시험하자. 일반 HTTP의 PC IP로 접속하면 보안 컨텍스트, 즉 브라우저가 보안 API 사용을 허용하는 조건을 못 갖춰 지문 계산이 막혀 .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;배포 환경에서는 보안 API와 리더·뷰어 신호 전달을 확인했어. 아이폰과 데스크톱도 된다는 확인이 있었지만, 웨일에서는 파일을 골라도 여전히 진행이 안 됐어   .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그때 폰 로그에는 보안 API가 있다고 나왔어. 대신 지문 계산 모듈이 준비되지 않아 초기화에서 예외가 났지. 파일명 차단을 의심해 이름을 바꾼 뒤 성공 확인을 받았어   .&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;영상 중계를 없애면 운영 준비는 줄어들지만, 각 폰에서 파일을 대조하고 재생하는 책임이 커져. 전송을 덜 하는 설계일수록 실제 기기에서 그 경로를 끝까지 확인해야 해.&lt;/div&gt;&lt;/div&gt;</description></item><item><title>캐릭터 쇼츠에 정보도 캐릭터 개성도 제대로 안 담길 때, 블로그 글로 옮길지 판단한 기준</title><link>https://bug-casebook.pages.dev/posts/shorts-to-blog-format-review/</link><guid>https://bug-casebook.pages.dev/posts/shorts-to-blog-format-review/</guid><description>&lt;p&gt;&lt;strong&gt;쇼츠에서 정보와 캐릭터가 모두 약하다고 느꼈다면, 글 시제품으로 표현이 나아지는지 확인할 이유가 있다. 이 사례는 시제품 제안에서 끝났고 전환 효과는 아직 확인되지 않았다  .&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;한국 현대사와 가계도를 가진 AI 캐릭터들이 그날의 이슈로 티격태격하는 유튜브 쇼츠를 대본부터 렌더링까지 만드는 프로젝트(personaverse)가 있었다  .&lt;/p&gt;
&lt;p&gt;같은 시기에 IT 문제 해결 글을 자동으로 만드는 블로그 파이프라인(content-loop-mvp)도 돌리고 있었다  .&lt;/p&gt;
&lt;p&gt;쇼츠를 더 올릴지 따져 보던 중, 만든 사람이 보기에 쇼츠 안에서 &lt;strong&gt;트렌드 정보도 제대로 전달되지 않고 캐릭터 특성도 제대로 드러나지 않는다&lt;/strong&gt;는 문제가 먼저 떠올랐다 .&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;만든 사람이 느낀 표현의 제약은 포맷을 다시 검토할 근거로 충분하다. 하지만 새 포맷이 낫다는 근거는 되지 못한다. 시제품 한 편으로 정보 전달과 말투를 먼저 검토하고, 독자 반응은 별도로 확인해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 기록에서 독자가 가져갈 판단 기준을 네 가지로 정리하면 다음과 같다.&lt;/p&gt;
&lt;p&gt;| 질문 | 이 사례에서 확인된 것 |&lt;/p&gt;
&lt;p&gt;|---|---|&lt;/p&gt;
&lt;p&gt;| 1. 콘텐츠의 목표가 몇 개이고, 지금 포맷에 둘 다 들어가나? | 목표는 &amp;quot;트렌드 정보 전달&amp;quot;과 &amp;quot;캐릭터 특성 발현&amp;quot; 두 가지였고, 쇼츠에서는 둘 다 제대로 안 됐다는 제작자 평가가 있었다  |&lt;/p&gt;
&lt;p&gt;| 2. 새 포맷으로 올 독자가 원하는 것과 캐릭터가 부딪히지 않나? | IT 문제 해결 글처럼 답을 빨리 원하는 검색에 캐릭터 만담을 얹으면 유용성을 깎는다는 지적이 나왔다. 캐릭터가 주인공인 생활문화·트렌드 글로 옮기거나, 정보 글은 두고 캐릭터를 곁가지로만 붙이는 두 갈래가 검토됐다  |&lt;/p&gt;
&lt;p&gt;| 3. 지금 손에 든 숫자가 정말 이 결정을 증명하나? | 그 뒤에 나온 &amp;quot;IT 글 후보 31개 중 통과 0개&amp;quot;는 IT 파이프라인의 통과율일 뿐, 쇼츠와 글을 비교한 실험이 아니다. 포맷 전환 제안은 이 결과보다 24분 먼저 나왔다   |&lt;/p&gt;
&lt;p&gt;| 4. 시제품 한 편으로 무엇을 확인할 건가? | 생활문화 트렌드 주제 1편을 캐릭터 2~3명으로 실제로 만들어 보고, 사실 전제는 출처로 확인하고 재미·말투는 사람이 읽고 판단하자는 제안이 나왔다   |&lt;/p&gt;
&lt;p&gt;이 기록이 끝나는 시점까지 확인되는 것은 &lt;strong&gt;&amp;quot;검토하고 시제품을 제안했다&amp;quot;&lt;/strong&gt;까지다. 시제품 결과, 블로그 게시, 방문·수익 지표는 기록에 없다 .&lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;쇼츠 쪽(personaverse)&lt;/strong&gt;: 캐릭터마다 유전 형질 → 3세대 가계도 → 현대사 → 누적 기억을 쌓는 구조이고, 영상은 &lt;code&gt;scene.json → Playwright 녹화 → ffmpeg 인코딩 → final.mp4&lt;/code&gt; 순서로 만든다. 더빙 없이 대사를 타이핑하듯 띄우고, 한글 초당 7자 읽기 속도에 맞춰 장면 시간을 정한다. 점검 당시 대본 초안은 한·일 합쳐 약 45편, 완성 &lt;code&gt;.mp4&lt;/code&gt;는 2개였다 &lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;블로그 쪽(content-loop-mvp)&lt;/strong&gt;: 주제 후보를 만들고(discover), 웹에서 근거를 모으고(research), 글을 쓰고(write), 게이트에서 심사해 통과한 글만 남기는 자동 생성 파이프라인. 주제는 IT 문제 해결이었다  &lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;&lt;li&gt;블로그 파이프라인의 통과율을 올리려고 주제 점수 문턱 &lt;code&gt;minimum_topic_score&lt;/code&gt;를 &lt;code&gt;75 → 68&lt;/code&gt;로 낮추고, 심사에 떨어진 글을 1회 고쳐 쓰는 repair를 넣은 뒤, 실제 모델로 배치를 돌려 둔 상태에서 쇼츠 프로젝트를 검토하기 시작했다   &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;쇼츠 프로젝트를 점검한 뒤 처음 나온 권고는 &amp;quot;기능 추가가 아니라 베스트 10~15편을 골라 렌더 → 업로드 → 실측 반응&amp;quot;이었다. 대본은 많은데 출시가 적다는 진단이었다 .&lt;/p&gt;
&lt;p&gt;그 직후 제작자가 다른 문제를 짚었다.&lt;/p&gt;
&lt;blockquote&gt;목표를 유튜브 쇼츠로 잡았기 때문에 사실 트렌드 정보를 제대로 전달하거나 캐릭터 특성들이 제대로 발현되지 못하는 면도 있었어. 이게 영상 포맷이 아니라 글 포맷이라면 지금 작업하는 블로그 게시물로 더 효과가 좋겠다는 생각이 들어. &lt;/blockquote&gt;
&lt;p&gt;이것은 제작자의 질적 평가다. 시청자 반응이나 조회 수를 잰 값이 아니고, 어느 대본의 어느 장면에서 무엇이 빠졌는지도 기록에 없다 .&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;두 목표가 한 포맷 안에서 서로 자리를 다툰다는 것이 검토의 출발점이었다. 이어진 분석은 &amp;quot;짧은 영상은 정보 깊이와 캐릭터 관점을 동시에 담기 어렵고, 분량 예산이 없는 텍스트는 이 제약을 풀어 준다&amp;quot;는 해석이었다 . 다만 이 해석은 제작자 증언을 설명하는 가설이다. 장면별 비교로 확인한 결과가 아니다.&lt;/p&gt;
&lt;p&gt;검토 중에 판단을 흐릴 뻔한 지점이 두 개 있었다.&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;&amp;quot;글로 옮기면 캐릭터를 아무 글에나 붙일 수 있다&amp;quot;는 가정.&lt;/strong&gt; IT 문제 해결을 검색한 사람은 해결법을 빨리 원하므로, 여기에 캐릭터 티격태격을 얹으면 두 목표를 다 놓친다는 반론이 나왔다. 그래서 선택지가 &amp;quot;캐릭터가 주인공인 트렌드·생활문화 의견 글&amp;quot;(모델 A)과 &amp;quot;IT 정보 글은 그대로, 캐릭터는 가벼운 코너로&amp;quot;(모델 B)로 나뉘었다 . 처음에는 모델 B를 권했지만, 다음 분석에서는 생활문화·트렌드 의견 쪽으로 기울었다. 그 사이에 어떤 대화가 오갔는지는 기록에 없다  &lt;/li&gt;&lt;/ol&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;&amp;quot;IT 파이프라인 0% 결과가 전환을 증명했다&amp;quot;는 해석.&lt;/strong&gt; 검토 도중 끝난 배치 결과는 후보 31개 중 통과 0개였다. 27개는 출처 간 사실 충돌(&lt;code&gt;source_conflict&lt;/code&gt;)로, 4개는 repair를 1회 거친 뒤에도 모델 심사에서 떨어졌다 . 이 결과를 두고 &amp;quot;방향 전환이 감이 아니라 데이터로 뒷받침됐다&amp;quot;는 평가도 나왔지만 , 같은 보고 안에서 게이트가 충돌을 하나만 찾아도 폐기하도록 짜여 있어 기기마다 메뉴가 다른 차이까지 충돌로 잡았을 수 있다고 인정했다 . 무엇보다 이것은 IT 글 파이프라인의 통과율이다. 캐릭터 글이 쇼츠보다 낫다는 증거가 아니다&lt;/li&gt;&lt;/ol&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;이 기록에서 &amp;quot;해결&amp;quot;은 전환 완료가 아니라 &lt;strong&gt;검증 계획&lt;/strong&gt;까지다.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;주제 범위: 정치·사건사고 같은 민감한 시사 대신 소비 트렌드·세대 문화·생활 밈 같은 생활문화 쪽으로 잡는다 &lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;&lt;li&gt;품질 게이트를 둘로 나눈다: 최저임금 금액 같은 &lt;strong&gt;사실 전제&lt;/strong&gt;에는 출처 확인 게이트를 남기고, &lt;strong&gt;재미·말투&lt;/strong&gt;는 사람의 편집 판단에 맡기자는 제안이었다 &lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;&lt;li&gt;재사용 구조: 블로그 파이프라인의 &lt;code&gt;discover → research → write → 게이트&lt;/code&gt; 골격에 캐릭터별 리서치 스킬(&lt;code&gt;persona-research&lt;/code&gt;)과 말투 카드(voice-cards)를 꽂고, 게이트에 캐릭터 정합성 검사를 더하는 방안이 제안됐다. 구현 완료 보고는 아니다 &lt;/li&gt;&lt;/ul&gt;
&lt;ul&gt;&lt;li&gt;첫 단계: 생활문화 트렌드 주제 1개로 캐릭터 글 1편을 실제로 뽑아 품질을 본다  &lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;그러니 같은 고민을 하는 사람에게 권할 순서는 이렇다.&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;제작자가 느낀 제약을 &lt;strong&gt;목표 단위로&lt;/strong&gt; 적는다 (&amp;quot;정보가 얕다&amp;quot;, &amp;quot;캐릭터가 안 산다&amp;quot;)&lt;/li&gt;&lt;/ol&gt;
&lt;ol&gt;&lt;li&gt;새 포맷으로 올 독자가 원하는 것과 캐릭터가 부딪히는지 본다&lt;/li&gt;&lt;/ol&gt;
&lt;ol&gt;&lt;li&gt;결정을 뒷받침하려고 가져온 숫자가 &lt;strong&gt;같은 질문에 대한 실험&lt;/strong&gt;인지 확인한다&lt;/li&gt;&lt;/ol&gt;
&lt;ol&gt;&lt;li&gt;새 포맷 시제품 1편을 만들고, 사실과 재미를 따로 판정한다&lt;/li&gt;&lt;/ol&gt;
&lt;h2&gt;덤 — 반대편의 실패는 이쪽의 성공 증거가 아니다&lt;/h2&gt;
&lt;p&gt;포맷을 바꾸자는 판단이 먼저 나오고, 24분 뒤 다른 파이프라인의 0% 결과가 도착했다  . 이미 기운 결정 앞에 숫자가 도착하면 &amp;quot;역시 맞았다&amp;quot;로 읽기 쉽다. 그 숫자가 어떤 질문에 대한 답인지부터 다시 물어야 한다. 이 경우 0/31은 &amp;quot;IT 글 자동 생성 게이트가 지금 설정으로 얼마나 통과시키나&amp;quot;에 대한 답이었고, 그마저도 게이트가 과하게 엄격했을 가능성이 함께 남아 있었다 .&lt;/p&gt;
&lt;h2&gt;취재 후기 — 올리기 전에 포맷부터&lt;/h2&gt;
&lt;blockquote&gt;*쇼츠 대본 폴더와 블로그 자동 생성 로그를 나란히 띄워 둔 오후.*&lt;/blockquote&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;우리 넷이 그날 이슈로 티격태격하는 쇼츠 말이야. 대본 초안은 한·일 합쳐 45편쯤 쌓였는데, 완성된 영상 파일은 두 개뿐이야 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 답은 간단하지. 괜찮은 거 10편쯤 골라서 영상 파일로 만들고 올려 보자. 만들기만 하고 안 내보내는 게 병목이잖아 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;올리기 전에 하나만 확인하자. 쇼츠로 하려던 게 두 가지였잖아. 트렌드 정보를 전하는 거랑 우리 성격이 드러나는 거. 영상에서 그게 실제로 됐는지부터 보자&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;내가 만들면서 느낀 건, 트렌드 정보도 제대로 전달이 안 되고 캐릭터 특성도 제대로 안 드러나는 면이 있어. 글로 풀었으면 더 나았겠다 싶더라 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이유는 길이일 거야. 짧은 영상 안에서 정보를 설명하면 성격 보여 줄 틈이 없고, 티격태격을 살리면 정보가 빠지는 거지 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 설명은 그럴듯한데 아직 가설이야. 어느 장면에서 뭐가 빠졌는지까지는 안 봤으니까. 어느 대목이 부족한지 짚고, 글에서는 달라지는지 확인해 보자&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;만들면서 느낀 제약도 검토할 이유는 돼. 다만 그 느낌만으로 독자 반응까지 알 수는 없어. 편수를 늘리기 전에 무엇을 확인할지 정하자&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 글로 옮기자. 분량 제한이 없으니 정보도 길게, 캐릭터도 길게. 지금 돌리는 IT 문제 해결 글 자동 생성기에 우리 대화를 얹으면 끝이야  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;얹기 전에 그 글을 읽으러 오는 사람을 떠올려 보자. 검색으로 들어온 사람이 첫 화면에서 뭘 찾는지가 갈림길이야&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;&amp;#x27;PS5 컨트롤러 연결 끊김&amp;#x27;을 검색해서 들어왔다고 쳐 봐. 첫 화면에 우리 수다가 있으면 해결법 찾으려고 스크롤부터 내릴 거야 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 IT 해결 글에 캐릭터를 그대로 얹는 안은 탈락이야. 캐릭터가 주인공인 생활문화·트렌드 글로 가거나, 정보 글은 두고 캐릭터는 곁가지 코너로만 붙이거나, 둘 중 하나야 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;포맷을 바꾸는 거랑 캐릭터를 아무 데나 붙이는 건 다른 결정이야. 독자가 온 이유를 먼저 못 채우면 캐릭터는 방해물이 돼&lt;/div&gt;&lt;/div&gt;
&lt;blockquote&gt;*그 사이 돌려 둔 IT 글 자동 생성 배치가 끝났다.*&lt;/blockquote&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;43분 돌렸는데 후보 31개 중에 통과 0개야. 27개는 출처끼리 사실이 엇갈린다고 버려졌고, 4개는 한 번 고쳐 쓰고도 심사에서 떨어졌어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;봐, 이게 증거야. IT 글은 자동으로 안 되니까 캐릭터 글로 가는 게 맞다는 거지&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;순서부터 보자. 글로 옮기자는 얘기는 이 결과가 나오기 24분 전에 이미 나왔어. 그리고 이건 IT 글 파이프라인 통과율이지, 쇼츠와 글을 비교한 실험이 아니야  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;글을 통과시킬지 정하는 심사 코드에서는 출처 충돌이 하나만 나와도 폐기하게 돼 있어서, 기기마다 메뉴가 다른 자연스러운 차이까지 충돌로 잡혔을 수 있어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 0%는 &amp;#x27;IT 글은 불가능하다&amp;#x27;도, &amp;#x27;캐릭터 글이 낫다&amp;#x27;도 증명하지 못해. 캐릭터 글이 낫다고 말하려면 캐릭터 글을 실제로 만들어 봐야 해&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다른 실험의 실패는 새 선택의 성공 증거가 아니야. 이미 기운 결정 앞에 반대편 숫자가 도착했을 때가 제일 위험해&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 이제 뭘 만들면 돼?&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;생활문화 트렌드 주제 하나로, 우리 중 두세 명이 나오는 글을 딱 한 편 만들자. 그리고 정보가 맞는지와 읽을 맛이 있는지를 따로 판정하는 거야  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;최저임금 금액 같은 사실 전제는 출처로 확인하고, 재미랑 말투는 사람이 직접 읽고 판단해야겠다. 재미와 말투는 사람의 편집 판단에 맡기자는 거지 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;포맷을 다시 볼 이유는 만든 사람이 느낀 제약이면 충분해. 시제품 한 편은 정보와 말투를 확인할 첫 재료야. 독자 반응까지 좋아지는지는 따로 확인해야 해&lt;/div&gt;&lt;/div&gt;</description></item><item><title>모바일 브라우저(웨일)에서만 안 되는데 콘솔을 볼 수가 없을 때 — ?debug=1 화면 디버그 패널</title><link>https://bug-casebook.pages.dev/posts/mobile-browser-no-console-debug-panel/</link><guid>https://bug-casebook.pages.dev/posts/mobile-browser-no-console-debug-panel/</guid><description>&lt;p&gt;무용 연습실에서 각자 준비한 영상을 리더의 조작에 맞춰 함께 보는 영상 동기화 웹앱을 만들고 있었다. 매번 노트북 서버를 켜는 번거로움을 줄이려고 클라우드로 옮겼는데, 안드로이드 웨일에서 영상을 골라도 방 만들기·참여 버튼이 켜지지 않았다.    &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;모바일 브라우저에서만 나는 버그를 추측으로 고치고 있다면, 멈추고 &lt;strong&gt;&lt;code&gt;?debug=1&lt;/code&gt;일 때만 화면에 뜨는 로그 패널&lt;/strong&gt;부터 넣는다. 핵심은 네 가지다.&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;맨 먼저 로드되는 스크립트&lt;/strong&gt;로 넣는다. 그래야 페이지 로드 시점에 터진 에러까지 잡힌다&lt;/li&gt;&lt;li&gt;첫 줄에 &lt;strong&gt;UA와 쓰는 API 지원 여부&lt;/strong&gt;를 찍는다&lt;/li&gt;&lt;li&gt;&lt;strong&gt;&lt;code&gt;window.onerror&lt;/code&gt;&lt;/strong&gt;(와 &lt;code&gt;unhandledrejection&lt;/code&gt;)를 화면으로 보낸다&lt;/li&gt;&lt;li&gt;&lt;strong&gt;복사 버튼&lt;/strong&gt;을 단다. 사용자가 로그를 그대로 붙여 넣을 수 있게&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;이 사건에서는 첫날 가설 네 개를 추측으로 쫓으며 코드를 여러 번 고쳤는데도 풀리지 않았고, 5일 뒤에도 증상은 그대로였다  . 패널을 배포하자 사용자가 첫 로그를 보냈고, 그 로그 한 줄로 원인이 바로 나왔다. 패널 커밋부터 수정 커밋까지 약 6분 걸렸다   .&lt;/p&gt;
&lt;p&gt;아래는 같은 구성을 재현한 &lt;strong&gt;예시 구현&lt;/strong&gt;이다(원본 &lt;code&gt;debug.js&lt;/code&gt; 코드가 아니다. 원본의 구성 요소는 에 나열돼 있다).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- 페이지의 첫 번째 &amp;lt;script&amp;gt;. 다른 어떤 스크립트보다 먼저 --&amp;gt;
&amp;lt;script src=&amp;quot;/debug.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;// debug.js — ?debug=1 일 때만 켜지는 화면 로그 패널 (예시)
(function () {
  const on = new URLSearchParams(location.search).get(&amp;#x27;debug&amp;#x27;) === &amp;#x27;1&amp;#x27;;
  const lines = [];
  let box;

  function t() { return new Date().toISOString().slice(11, 23); }
  function render() { if (box) box.textContent = lines.join(&amp;#x27;\n&amp;#x27;); }

  window.dbg = function (...args) {
    if (!on) return;
    const msg = args.map(a =&amp;gt; typeof a === &amp;#x27;string&amp;#x27; ? a : JSON.stringify(a)).join(&amp;#x27; &amp;#x27;);
    lines.push(t() + &amp;#x27;  &amp;#x27; + msg);
    render();
  };
  if (!on) return;

  // 1) 페이지 로드 중 에러도 잡도록 가장 먼저 등록
  window.addEventListener(&amp;#x27;error&amp;#x27;, e =&amp;gt;
    dbg(&amp;#x27;❌ window.error:&amp;#x27;, e.message, &amp;#x27;@&amp;#x27;, (e.filename || &amp;#x27;&amp;#x27;) + &amp;#x27;:&amp;#x27; + e.lineno + &amp;#x27;:&amp;#x27; + e.colno));
  window.addEventListener(&amp;#x27;unhandledrejection&amp;#x27;, e =&amp;gt;
    dbg(&amp;#x27;❌ unhandledrejection:&amp;#x27;, String(e.reason)));

  // 2) 환경 한 줄: 가설을 한 번에 지우는 줄
  dbg(&amp;#x27;=== debug on ===&amp;#x27;);
  dbg(&amp;#x27;UA:&amp;#x27;, navigator.userAgent);
  dbg(&amp;#x27;secureContext:&amp;#x27;, window.isSecureContext,
      &amp;#x27;| crypto.subtle:&amp;#x27;, !!(window.crypto &amp;amp;&amp;amp; crypto.subtle),
      &amp;#x27;| Blob.arrayBuffer:&amp;#x27;, typeof Blob !== &amp;#x27;undefined&amp;#x27; &amp;amp;&amp;amp; &amp;#x27;arrayBuffer&amp;#x27; in Blob.prototype,
      &amp;#x27;| File.text:&amp;#x27;, typeof File !== &amp;#x27;undefined&amp;#x27; &amp;amp;&amp;amp; &amp;#x27;text&amp;#x27; in File.prototype);

  // 3) 패널 + 복사 버튼 (body가 생긴 뒤 붙이기)
  document.addEventListener(&amp;#x27;DOMContentLoaded&amp;#x27;, () =&amp;gt; {
    const wrap = document.createElement(&amp;#x27;div&amp;#x27;);
    wrap.style.cssText = &amp;#x27;position:fixed;left:0;right:0;bottom:0;max-height:40vh;overflow:auto;&amp;#x27; +
      &amp;#x27;background:#000c;color:#0f0;font:11px/1.4 monospace;z-index:99999;padding:6px&amp;#x27;;
    const btn = document.createElement(&amp;#x27;button&amp;#x27;);
    btn.textContent = &amp;#x27;복사&amp;#x27;;
    btn.onclick = () =&amp;gt; navigator.clipboard.writeText(lines.join(&amp;#x27;\n&amp;#x27;))
      .then(() =&amp;gt; btn.textContent = &amp;#x27;복사됨&amp;#x27;, () =&amp;gt; btn.textContent = &amp;#x27;복사 실패&amp;#x27;);
    box = document.createElement(&amp;#x27;pre&amp;#x27;);
    box.style.cssText = &amp;#x27;margin:0;white-space:pre-wrap&amp;#x27;;
    wrap.append(btn, box);
    document.body.append(wrap);
    render();
  });
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그다음 의심 가는 파이프라인에 &lt;code&gt;dbg()&lt;/code&gt;를 한 줄씩 끼운다. 이 사건에서는 파일 정보 → 길이 읽기 시작/완료 → 지문 계산 시작/완료 → 버튼 &lt;code&gt;disabled&lt;/code&gt; 최종값 순서였다 .&lt;/p&gt;
&lt;div class="table-wrap"&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;chrome://inspect&lt;/code&gt; 원격 디버깅&lt;/th&gt;&lt;th&gt;화면 디버그 패널 (&lt;code&gt;?debug=1&lt;/code&gt;)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;준비물&lt;/td&gt;&lt;td&gt;PC, USB 케이블, 폰의 개발자 옵션 → USB 디버깅 &lt;/td&gt;&lt;td&gt;없음. URL에 &lt;code&gt;?debug=1&lt;/code&gt;만 붙이면 됨&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;보이는 것&lt;/td&gt;&lt;td&gt;콘솔·네트워크 등 진짜 DevTools 전부&lt;/td&gt;&lt;td&gt;직접 심은 로그 + 전역 에러만&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;버그가 난 사람의 폰에서&lt;/td&gt;&lt;td&gt;그 폰을 PC에 꽂아야 함&lt;/td&gt;&lt;td&gt;링크만 보내면 됨. 로그는 복사해서 받음&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;이 사건에서&lt;/td&gt;&lt;td&gt;시도하지 않음&lt;/td&gt;&lt;td&gt;첫 로그로 원인 확정 &lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;네트워크 요청까지 봐야 하면 &lt;code&gt;chrome://inspect&lt;/code&gt;가 낫다. &amp;quot;어디서 죽었는지&amp;quot;만 알면 되는 경우라면 패널이 훨씬 빠르다.&lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;리더가 연습방을 열고 영상을 선택하면, 참가자는 방 목록에서 같은 방에 들어가 각자 미리 받은 영상 파일을 고른다. 서버는 영상 대신 동기화 신호를 전달하고, 참가자의 재생은 리더 조작을 따른다.  &lt;/p&gt;
&lt;p&gt;같은 영상인지 확인하는 지문 계산 헬퍼는 별도 스크립트였다. 파일 선택 뒤 길이 읽기와 지문 계산을 거쳐 버튼 상태를 정하고, 뷰어는 서버에서 방 정보를 받아 영상 대조에 쓴다. 따라서 버튼이 꺼져 있다는 관찰만으로 어느 단계가 고장 났는지는 알 수 없었다.   &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;Cloudflare Workers로 띄운 웹앱. 안드로이드에서 영상 파일을 고르면 파일명은 보이는데 &amp;quot;참여&amp;quot;·&amp;quot;방 만들기&amp;quot; 버튼이 켜지지 않았다 &lt;/li&gt;&lt;li&gt;뷰어 화면은 &amp;quot;방 정보를 불러오는 중&amp;quot;에서 멈췄다. 아이폰·데스크톱은 정상이었다 &lt;/li&gt;&lt;li&gt;같은 폰이라도 일반 크롬에서는 됐고, 웨일 브라우저에서만 안 됐다 &lt;/li&gt;&lt;li&gt;5일 뒤에도 같은 증상이 그대로였다 &lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;이 사건에서는 문제 기기의 콘솔 로그 없이 수정을 반복했고, 이후 모바일 디버깅 방법을 찾으면서 화면 패널을 넣었다.  &lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;두 층위로 봐야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;버그 자체의 원인.&lt;/strong&gt; 패널이 찍은 첫 로그는 이랬다(배포 주소는 제거했다) .&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UA: Mozilla/5.0 (Linux; Android 10; K) ... Chrome/138.0.0.0 Whale/3.9.14.9 Mobile Safari/537.36
secureContext: true | crypto.subtle: true | Blob.arrayBuffer: true | File.text: true
❌ window.error: Uncaught TypeError: Cannot destructure property &amp;#x27;fingerprintVideo&amp;#x27; of &amp;#x27;window.FormationFingerprint&amp;#x27; as it is undefined. @ /leader?debug=1:172:13
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;파일을 고르기도 전, 페이지를 열자마자 에러가 났다. 별도 파일로 둔 영상 지문 헬퍼 &lt;code&gt;fingerprint.js&lt;/code&gt;가 웨일에서만 로드되지 않았다. 그래서 인라인 스크립트가 맨 위 구조분해에서 죽었고, 그 아래의 파일 핸들러·WebSocket 연결·버튼 활성화 코드가 전부 등록되지 않았다  . 버튼이 안 켜진 것도, &amp;quot;방 정보를 불러오는 중&amp;quot;에서 멈춘 것도 이 에러 하나로 설명된다 .&lt;/p&gt;
&lt;p&gt;파일 이름을 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꾸자 웨일에서도 됐다  . 바꾼 것은 파일명과 참조뿐이고, 전역 이름 &lt;code&gt;FormationFingerprint&lt;/code&gt;와 함수 이름은 그대로 뒀다 . 그래서 막힌 기준은 파일명(URL)이었다고 보는 게 자연스럽다. 다만 웨일의 어떤 기능이나 필터 목록이 막았는지는 확인하지 않았다. &amp;quot;추적 방지 기능이 &lt;code&gt;fingerprint&lt;/code&gt;를 지문 추적 스크립트로 오인했다&amp;quot;는 설명은 에이전트의 추론이다 .&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫날 원인을 못 잡은 이유.&lt;/strong&gt; 에러를 볼 수단이 없어서 증상만 보고 고쳤다. 5일은 계속 매달린 시간이 아니라 첫날(8월 25일) 시도와 다음 시도(8월 30일) 사이의 간격이다. 확인된 사실은 이 둘뿐이다.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;1차에는 가설 네 개(길이 읽기 무한 대기, WebSocket 불통, 웨일의 WS 차단, 안드로이드 사진 선택 도구)를 차례로 추측했고, 그에 맞춰 코드를 여러 번 바꿨지만 증상은 그대로였다     &lt;/li&gt;&lt;li&gt;2차에는 사용자가 &amp;quot;모바일 브라우저 콘솔이나 디버깅 도구에 접근할 방법이 있다면&amp;quot;이라고 제안했고, 거기서 패널로 방향이 바뀌었다  &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;h3&gt;1. 두 길 중 패널을 고른 이유&lt;/h3&gt;
&lt;p&gt;에이전트는 &lt;code&gt;chrome://inspect&lt;/code&gt; 원격 디버깅과 앱 안 디버그 패널을 함께 제시했다. 원격 디버깅은 케이블과 설정이 필요했다. 패널은 웨일에서 설정 없이 바로 된다는 이유로 패널을 먼저 택했다 . 에이전트는 웨일도 원격 디버깅이 &amp;quot;대개 된다&amp;quot;고 했지만, 이 사건에서는 시도하지 않았으므로 여기서는 확인하지 않은 주장으로 둔다 .&lt;/p&gt;
&lt;h3&gt;2. 패널 구성&lt;/h3&gt;
&lt;p&gt;사용법 안내에 적힌 구성은 이렇다 .&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;켜는 방법: 리더는 &lt;code&gt;.../leader.html?debug=1&lt;/code&gt;, 뷰어는 기존 쿼리 뒤에 &lt;code&gt;&amp;amp;debug=1&lt;/code&gt;&lt;/li&gt;&lt;li&gt;맨 위: &lt;strong&gt;UA, secureContext, crypto.subtle, Blob.arrayBuffer, File.text&lt;/strong&gt; 지원 여부&lt;/li&gt;&lt;li&gt;파이프라인 단계: 파일 정보(이름/크기/타입) → &lt;code&gt;readDuration&lt;/code&gt; 시작·완료·에러 → &lt;code&gt;fingerprint&lt;/code&gt; 시작·완료·에러 → &lt;code&gt;setupBtn.disabled&lt;/code&gt; / &lt;code&gt;go_btn.disabled&lt;/code&gt; 최종값&lt;/li&gt;&lt;li&gt;하단 고정 초록색 패널 + &lt;strong&gt;&amp;quot;복사&amp;quot; 버튼&lt;/strong&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;debug.js&lt;/code&gt;는 두 페이지 모두에 &lt;strong&gt;첫 번째 스크립트&lt;/strong&gt;로 넣었다 . 이 사건에서 가장 중요했던 결정이 이것이다. 실제 에러는 파일 선택 파이프라인이 아니라 페이지 로드 시점에 났다. 패널이 앱 스크립트보다 먼저 떠 있고 &lt;code&gt;window.error&lt;/code&gt;를 듣고 있었기 때문에 잡을 수 있었다 .&lt;/p&gt;
&lt;h3&gt;3. 첫 줄이 한 일&lt;/h3&gt;
&lt;p&gt;패널 설계 단계에서 에이전트는 로그를 읽는 기준을 미리 정해 뒀다. &lt;code&gt;crypto.subtle: false&lt;/code&gt;면 웨일이 보안 API를 막는 것, &lt;code&gt;size: 0&lt;/code&gt;이면 파일 바이트가 넘어오지 않는 것, 이런 식이다 . 실제 첫 줄은 네 항목이 모두 &lt;code&gt;true&lt;/code&gt;였다 . 이 값은 해당 환경·API의 존재 여부를 보여줄 뿐, 실제 호출 성공까지 보장하지는 않는다. 이 사건에서 실행 중단 지점을 알려준 결정적 증거는 바로 아래의 &lt;code&gt;window.error&lt;/code&gt; 한 줄이었다.  &lt;/p&gt;
&lt;h3&gt;4. 로그를 받은 뒤&lt;/h3&gt;
&lt;ul&gt;&lt;li&gt;에이전트는 처음에 URL이 &lt;code&gt;.html&lt;/code&gt; 없는 &lt;code&gt;/leader&lt;/code&gt;라서 상대경로가 꼬였다고 의심했다 . 하지만 데스크톱에서 &lt;code&gt;/leader&lt;/code&gt;로 열어도 정상이어서 그 가설은 바로 버렸다 &lt;/li&gt;&lt;li&gt;&lt;code&gt;fingerprint.js&lt;/code&gt; → &lt;code&gt;mediacheck.js&lt;/code&gt;로 이름을 바꾸고, 파일 헤더 주석에 이름을 바꾼 이유를 남겼다  &lt;/li&gt;&lt;li&gt;데스크톱에서 회귀가 없는지 확인한 뒤 커밋했다  &lt;/li&gt;&lt;li&gt;사용자: &amp;quot;된다!! 됐어! 잘했다!&amp;quot; &lt;/li&gt;&lt;li&gt;패널은 지우지 않고 남겨서, 이후 모바일 문제에도 그대로 쓰기로 했다 &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;덤 — 같은 사건의 곁가지 교훈&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;진단 코드는 앱 코드와 같은 배에 태우지 않는다.&lt;/strong&gt; 1차 때도 뷰어 &amp;quot;함께 보기&amp;quot; 화면에 연결 상태를 띄우는 진단 표시를 넣었다 . 하지만 그 표시에 무엇이 떴는지는 보고되지 않았고, 사용자는 곧 &amp;quot;크롬은 되고 웨일은 안 된다&amp;quot;는 쪽으로 넘어갔다 . 페이지 스크립트가 로드 직후 죽는 상황이라면, 같은 스크립트 안에 든 진단 코드도 함께 죽는다. 그 진단 표시가 정확히 어느 스크립트에 있었는지는 세션에서 확인되지 않지만, 별도 파일로 &lt;strong&gt;먼저&lt;/strong&gt; 로드되는 패널이 이 함정을 피한다는 점은 분명하다&lt;/li&gt;&lt;li&gt;&lt;strong&gt;배포 확인은 가능하면 브라우저로 한다.&lt;/strong&gt; 패널 배포를 curl + grep으로 확인하다가 &lt;code&gt;debug.js&lt;/code&gt; 참조가 0개로 나왔다. 처음에는 엣지 캐시를 의심했다  . 다음에는 gzip 응답을 풀지 않은 탓으로 보고 &lt;code&gt;--compressed&lt;/code&gt;를 붙였지만 이번엔 0바이트가 나왔다  . 결국 브라우저로 열어서 정상 배포를 확인했다 . curl이 왜 꼬였는지는 끝내 확정하지 않았다&lt;/li&gt;&lt;li&gt;&lt;strong&gt;파일 선택기 삽질은 곁가지였다.&lt;/strong&gt; 첫날 &lt;code&gt;accept&lt;/code&gt;를 세 번 바꾼 수정은 웨일 증상을 풀지 못했다 . 원인을 찾은 뒤 에이전트는 확장자 필터가 안 됐던 것도 &lt;code&gt;fingerprint.js&lt;/code&gt; 차단이 스크립트를 죽이고 있었기 때문이라고 보고, &lt;code&gt;video/*&lt;/code&gt; 같은 MIME 없이 확장자만 쓰는 필터를 되살렸다   . 되살린 필터가 웨일에서 잘 됐는지는 세션에 나오지 않는다&lt;/li&gt;&lt;li&gt;&lt;strong&gt;스크립트 파일명에 &lt;code&gt;fingerprint&lt;/code&gt;를 쓰지 않는다.&lt;/strong&gt; 이 사건에서는 이름만 바꾸니 해결됐다  . 다만 특정 단어를 피하면 모든 차단 문제를 예방할 수 있다는 뜻은 아니다. 여기서 확인한 해결은 이 파일의 이름 변경이다&lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;취재 후기 — 버튼을 고치기 전에 첫 에러부터&lt;/h2&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;연습실에서 각자 폰으로 같은 영상을 맞춰 보는 웹앱을 만들고 있잖아. 내 안드로이드에선 파일명은 뜨는데 방 만들기와 참여 버튼이 안 켜져.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;영상을 골랐다는 표시만으로 준비가 끝났다고 볼 수는 없어. 길이 읽기, 같은 영상인지 확인하는 지문 계산, 버튼 활성화 중 어디서 멈췄는지 나눠 보자. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;&lt;code&gt;loadedmetadata&lt;/code&gt;, 영상 길이 같은 기본 정보가 준비됐다는 신호가 안 오는 것 아닐까? 길이 읽기를 계속 기다리면 버튼도 켜지지 않을 거야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 가설대로라면 기다림을 끊었을 때 진행돼야겠네. 길이를 못 읽어도 6초 뒤 넘어가게 하고, 서버도 길이를 모르는 파일을 받도록 바꿔 확인하자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;바꾼 뒤 다시 해봤는데 여전히 참여 버튼이 꺼져 있어. 아이폰과 데스크톱은 되고, 이 폰은 ‘방 정보를 불러오는 중’에서 멈춰. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 길이 읽기 대기를 풀었다고 해결됐다는 판정은 취소야. 방 정보를 못 받는다는 관찰도 생겼으니, 파일 처리만 볼 수는 없겠어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;기다림을 제한하는 코드는 대비책이 될 수 있어도 원인의 증거는 아니야. 문제 기기에서 그대로라면 ‘고쳤다’는 말부터 거둬야 해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;방 정보를 못 받는다면 WebSocket, 서버와 연결을 유지하며 메시지를 주고받는 통로가 문제 아닐까? 영상 대조에 필요한 방 정보도 그쪽으로 오잖아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;배포된 뷰어를 데스크톱에서 열어 연결과 방 정보 수신을 확인하자. 폰 화면에도 연결 상태를 표시해서, 어느 환경에서 끊기는지 비교해 보자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;데스크톱 뷰어는 연결되고 방 정보도 받아. 폰에서도 브라우저를 비교해 보니 일반 크롬은 되고, 웨일에서만 안 되는 거였어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;서버 전체의 연결 장애로 설명하기는 어려워졌어. 다만 데스크톱 성공만으로 웨일의 연결까지 정상이라고 판정할 수는 없어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;‘방 정보 대기 중’은 결과 화면이지 통신 장애를 직접 찍은 로그가 아니야. 연결을 시작하는 코드가 실행됐는지부터 확인할 필요가 있어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;크롬은 되는데 웨일만 안 되니까, 웨일의 차단 기능이 통신을 막는 것 아닐까? 브라우저에 따라 갈리는 증상은 그 가설과 맞아 보여.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 웨일의 연결 상태와 차단 설정을 바꾼 결과가 있어야 해. 연결 실패가 실제로 찍히는지 확인하기 전에는 차단이라고 확정하지 말자. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;지금 눈에 보이는 다른 단서도 있어. 파일을 열면 사진 앱처럼 보이는 창이 뜨고, 기본 설정을 지워도 클라우드 미디어 안내가 나와.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그건 파일 선택 화면의 관찰이야. 통신 차단을 확인한 결과는 아직 없으니 그 가설은 미확인으로 남겨 두고, 선택 과정은 따로 보자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;브라우저가 다르다는 단서로 어느 기능이 막혔는지까지 알 수는 없어. 통신 차단으로 단정하면 실제로 멈춘 코드를 놓치게 돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;사진 선택 도구, 그러니까 사진과 영상을 고르는 전용 창으로 들어가서 문제인 것 아닐까? 클라우드 미디어 안내가 뜬다는 게 근거야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;&lt;code&gt;accept&lt;/code&gt;는 파일 입력에서 고를 형식을 지정하는 속성이야. 영상 형식 지정과 확장자 필터를 차례로 바꾸고, 그래도 안 되면 속성을 빼서 비교하자.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;동선용 JSON 파일은 고를 수 있는데 영상은 수정 뒤에도 안 돼. 필터를 아예 뺀 뒤에도, 5일 후 다시 해보니 재생 가능한 상태로 넘어가지 않아.    &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;선택 조건 변경으로 이 증상이 해결되지는 않았어. 이제는 화면에 로그를 띄워서 파일 선택 전후 중 어느 시점에 에러가 나는지 확인하자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;선택 창이 수상해 보여도 버튼이 멈춘 원인과 같다는 보장은 없어. 바꿔도 그대로라면 다음 추측보다 실행 기록을 얻는 데 시간을 써야 해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;케이블로 PC에 연결하는 원격 디버깅도 있지만, 이번엔 &lt;code&gt;?debug=1&lt;/code&gt; 화면 패널을 먼저 넣자. 앱보다 먼저 읽히게 해서 시작할 때 나는 에러도 잡자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;UA라는 브라우저 식별 정보와 API, 즉 코드가 쓰는 기능의 지원 여부도 찍자. 파일 처리 단계별 로그와 복사 버튼을 함께 두면 어디서 멈췄는지 읽을 수 있겠어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;웨일에서 페이지를 열자마자 에러가 보여. 파일은 아직 안 골랐어. &lt;code&gt;window.FormationFingerprint&lt;/code&gt;가 없어서 &lt;code&gt;fingerprintVideo&lt;/code&gt;를 꺼낼 수 없다고 나와. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;영상 지문 함수를 담는 객체가 아예 없네. 주소가 &lt;code&gt;.html&lt;/code&gt; 없는 &lt;code&gt;/leader&lt;/code&gt;라서 별도 파일인 &lt;code&gt;fingerprint.js&lt;/code&gt;를 찾는 상대경로가 어긋난 것 아닐까? &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 경로 그대로 데스크톱에서도 열어 보자. 파일이 준비됐는지와 그 객체가 있는지 확인하면, 경로만으로 같은 문제가 나는지 비교할 수 있어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;데스크톱은 &lt;code&gt;/leader&lt;/code&gt;로 열어도 객체가 있어. 웨일에서 본 건 파일 선택 뒤의 실패가 아니라 페이지를 열자마자 그 객체가 없다는 에러야.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;경로만의 문제라는 가설은 접자. 앞부분에서 실행이 멈춰 파일 선택 처리와 서버 연결도 시작하지 못한 거야. 두 증상이 같은 시작 오류로 설명돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;파일을 고른 다음 멈춘 것처럼 보여도 실제 실패는 그보다 앞이었네. 진단 코드를 앱보다 먼저 읽혀야 한다는 이유가 바로 이거야.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;파일명을 &lt;code&gt;mediacheck.js&lt;/code&gt;로 바꾸고 두 페이지의 참조도 바꿨어. 함수와 전역 객체 이름은 유지했고 데스크톱에서도 확인했어. 이제 웨일 결과를 보자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이제 돼! 이름을 바꾼 뒤에는 진행할 수 있어. 다만 지금 확인한 성공만으로 웨일의 어느 차단 설정이 원인이었는지까지 알 수는 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;첫 에러가 있어야 고장 난 단계를 좁힐 수 있어. 콘솔을 바로 볼 수 없는 기기에서는 추측을 더하기 전에, 앱보다 먼저 실행되는 로그 패널부터 준비하자.   &lt;/div&gt;&lt;/div&gt;</description></item><item><title>선택한 로컬 영상을 다음 페이지에서 다시 골라야 한다면</title><link>https://bug-casebook.pages.dev/posts/local-video-file-page-navigation/</link><guid>https://bug-casebook.pages.dev/posts/local-video-file-page-navigation/</guid><description>&lt;p&gt;여러 사람이 각자 준비한 안무 영상을 리더의 조작에 맞춰 재생하는 무용 연습용 웹앱(formation)을 만들고 있었다. 클라우드판을 로컬 개발 서버와 브라우저에서 구현·검증하던 중, 로비에서 영상을 고른 뒤 리더 페이지로 이동하려던 흐름에 제약이 드러났다. 선택한 영상 &lt;code&gt;File&lt;/code&gt;을 그 이동 너머로 유지하는 문제 때문에 방 개설 폼의 위치를 바꿨다.    &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;파일을 사용할 리더 페이지로 먼저 이동한 뒤 영상을 고르게 했다.&lt;/strong&gt; &lt;code&gt;leader.html&lt;/code&gt; 안에 개설 모드(&lt;code&gt;create-mode&lt;/code&gt;)와 제어 모드(&lt;code&gt;control-mode&lt;/code&gt;)를 두고, 파일 선택 → 방 생성 → 재생 제어를 이어 붙였다. 로비에는 방 목록과 &lt;code&gt;새 연습방 만들기&lt;/code&gt; 버튼을 남겼다.  &lt;/p&gt;
&lt;div class="table-wrap"&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;변경 전 계획&lt;/th&gt;&lt;th&gt;변경 후 흐름&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;로비 개설 폼에서 영상 선택 → 방 개설 성공 → 리더 페이지로 이동&lt;/td&gt;&lt;td&gt;로비에서 리더 페이지로 이동 → 이름·영상 선택 → 방 만들기 → 같은 페이지의 제어 모드&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;위 흐름은 당시 구현 계획과 변경 보고, 이후 수동 시험 안내에서 확인된다. 수정 전 파일 유실을 재현한 로그는 제시되지 않았으므로, 이 글은 &lt;strong&gt;구현 중 발견한 제약에 따른 설계 변경&lt;/strong&gt;을 다룬다.    &lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;목표는 연습할 때마다 노트북 서버를 띄우고 같은 와이파이에서 접속하던 번거로움을 줄이는 것이었다. 영상은 미리 서로 공유하고, 앱은 공개된 접속 경로에서 동기화 신호를 전달하도록 방향을 잡았다. 참가자는 방 목록에서 방을 선택하고 자기 기기의 영상 파일을 골라 리더의 재생 조작을 따라간다.  &lt;/p&gt;
&lt;p&gt;이번 문제에서 중요한 구성은 세 화면이다. 로비는 방을 찾는 곳, 리더 페이지는 방을 만들고 재생을 조작하는 곳, 뷰어 페이지는 참가자가 자기 영상을 고르고 참여하는 곳으로 정리됐다. 서버 측 신호 전달을 검증한 다음 이 화면들을 연결하는 단계에서 개설 폼의 위치가 바뀌었다.    &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;처음 로비에는 개설 폼과 방 목록이 함께 있었다. 다음 구현 계획도 “방 개설 성공 → 리더 페이지로”였다. 그런데 리더 페이지를 작성하면서 “로비에서 이동하면 영상 &lt;code&gt;File&lt;/code&gt;을 유지할 수 없다”는 이유로 개설 기능을 그 안으로 옮겼다. 즉 이 사건의 출발점은 이용자의 파일 유실 신고가 아니라, 파일 선택과 재생을 서로 다른 페이지에 둔 계획의 수정이었다.   &lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;당시 구현에서 짚은 제약은 &lt;strong&gt;로비에서 선택한 파일을 보유하는 흐름과 리더 페이지에서 그 파일을 재생하는 흐름이 이어지지 않는다는 것&lt;/strong&gt;이었다. 방 개설 뒤 페이지를 이동하도록 계획했지만, 실제 구현은 파일을 고르는 곳을 리더 페이지로 옮기는 방향으로 바뀌었다.  &lt;/p&gt;
&lt;p&gt;여기서 얻는 설계상의 해석은 “서버에 방이 생겼다”와 “재생할 화면에 로컬 영상이 준비됐다”를 별도 조건으로 보자는 것이다. 이 사례에서는 두 조건을 같은 리더 페이지 안에서 충족시키는 방법을 택했다. 모든 종류의 화면 전환에서 파일 전달이 불가능하다는 결론이나, 별도 저장·전달 방법을 시험해 배제했다는 결론까지 넓힐 근거는 없다.  &lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;로비의 개설 폼을 &lt;code&gt;leader.html&lt;/code&gt;로 옮겨 개설 모드와 제어 모드로 구성했다. 이후 시험 안내도 리더 페이지에서 방 이름과 실제 영상을 선택하고, 방을 만든 다음 재생·정지·재생 위치 이동을 조작하는 순서다.   &lt;/p&gt;
&lt;p&gt;변경 후 검증은 리더 페이지의 실제 처리 경로를 대상으로 했다. 브라우저 안에서 &lt;code&gt;MediaRecorder&lt;/code&gt;로 재생 가능한 시험 영상을 만들어 파일 입력에 주입했고, 지문·길이 계산과 방 생성, &lt;code&gt;createObjectURL&lt;/code&gt;을 통한 로컬 재생(&lt;code&gt;readyState=4&lt;/code&gt;), 동기화 신호 전달이 확인됐다는 보고가 남아 있다. 이는 자동화한 시험 영상의 결과이며 실제 휴대폰 파일 선택 시험을 모두 끝냈다는 뜻은 아니다.    &lt;/p&gt;
&lt;p&gt;검증 도중 뷰어가 0초에서 멈추기도 했다. 그때도 뷰어의 지문 일치·참여·로컬 영상 준비는 확인된 상태였고, 깨끗하게 다시 로드한 뒤에는 재생 위치가 진행하며 리더를 따라갔다. 따라서 그 관찰을 “로비에서 파일을 잃어버린 현상”의 증거로 가져올 수는 없다.  &lt;/p&gt;
&lt;p&gt;후속 변경에서는 리더 개설 폼에 선택적인 동선 파일 입력을 추가했고, 배포 후 시험 안내에도 &lt;code&gt;새 연습방 만들기&lt;/code&gt; → 이름·영상 선택 → 방 만들기 → 재생 순서가 유지됐다. 개설 폼을 옮긴 선택은 이 후속 흐름과도 일치한다.  &lt;/p&gt;
&lt;h2&gt;취재 후기 — 방을 만들었다고 영상까지 건너온 건 아니다&lt;/h2&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;각자 폰에 준비한 안무 영상을 한 명의 조작에 맞춰 보는 앱이잖아. 로비에서 영상을 고르고 방을 만든 다음, 리더 화면으로 보내는 흐름을 잡았어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그런데 리더 화면을 붙이는 단계에서 선택한 &lt;code&gt;File&lt;/code&gt;, 그러니까 영상 파일 객체를 페이지 이동 뒤에도 유지하는 문제가 걸렸어. 그래서 개설 위치를 바꾸기로 했지. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;방을 만든 다음 어디로 갈지만 정하면 부족하겠네. 그 화면에서 쓸 영상이 어디서 준비되는지도 같은 흐름에 넣어 판단해야겠어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 리더 페이지로 먼저 가서 고르자. &lt;code&gt;create-mode&lt;/code&gt;는 방을 만드는 화면, &lt;code&gt;control-mode&lt;/code&gt;는 재생을 조작하는 화면으로 두면 되겠네. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;로비에 있던 개설 폼도 옮겼어. 이제 로비는 방 목록과 새 방 만들기 버튼을 보여주고, 참가자는 참여 링크로 자기 화면에 들어가. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;바꾼 리더 화면은 브라우저에서 시험했어. &lt;code&gt;MediaRecorder&lt;/code&gt;, 브라우저의 녹화 기능으로 시험 영상을 만든 뒤 파일 입력에 넣어 실제 처리 경로를 돌렸지. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;확인할 것은 방 생성만이 아니야. 영상 지문과 길이를 구하고, 고른 영상을 로컬에서 재생하고, 동기화 신호를 보내는 데까지 이어지는지 봐야 해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 경로는 통과했어. &lt;code&gt;createObjectURL&lt;/code&gt;, 선택한 파일을 재생에 쓸 주소로 연결하는 호출을 거쳐 로컬 재생이 됐고, 방 생성과 신호 전달도 확인했어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다만 시험 영상으로 확인한 범위야. 실제 영상은 리더 페이지에서 이름과 함께 선택하고 방을 만든 뒤 조작하도록 시험 순서를 따로 남겼어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번 선택에서 배울 것은 파일을 고르는 위치도 화면 설계의 일부라는 거야. 다음 화면에서 할 일을 정할 때, 그 화면이 사용할 파일을 어디서 준비할지도 함께 정해야 해.  &lt;/div&gt;&lt;/div&gt;</description></item><item><title>아이폰에서만 영상이 끊기고 리더와 싱크가 계속 어긋날 때</title><link>https://bug-casebook.pages.dev/posts/iphone-video-stutter-sync-controls/</link><guid>https://bug-casebook.pages.dev/posts/iphone-video-stutter-sync-controls/</guid><description>&lt;p&gt;무용 연습용 영상 동기화 웹앱(formation)을 만들고 있었다. 연습실에서 한 사람(리더)이 노트북으로 음악과 안무 영상을 틀면, 나머지 멤버들은 각자 폰으로 같은 영상과 대형(무용수 위치) 오버레이를 맞춰 보는 프로그램이다. 다른 기기는 괜찮은데 아이폰 12 Pro에서만 영상이 끊기고, 리더와 맞았다 싶으면 다시 느려지며 어긋나기를 반복했다.      &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;여러 기기의 영상을 동기화하다 아이폰에서만 끊긴다면, 화질을 더 낮추기 전에 영상 보정 전후의 오차를 보자.&lt;/strong&gt; 이 사례에서는 미리받기(Blob)도 끊겼지만 동선만 표시하면 매끄러웠다. 이후 리더와의 오차가 0.45~0.5초에 머무는 것을 확인하고, seek 목표를 현재 리더 시각보다 앞당기는 보정을 적용하자 개발자가 &amp;quot;됐다!&amp;quot;고 답했다.    &lt;/p&gt;
&lt;p&gt;적용한 핵심은 &lt;code&gt;exp&lt;/code&gt; 대신 &lt;strong&gt;&lt;code&gt;exp + seekComp&lt;/code&gt;로 seek&lt;/strong&gt;하고, 착지 후 남은 오차로 &lt;code&gt;seekComp&lt;/code&gt;를 갱신하는 방식이었다. 기록상 초기 보정값은 0.45초, 재동기화 임계값은 0.30초, seek 쿨다운은 1초였다. 이 값은 해당 실험의 설정이며, 모든 아이폰에 적용할 정답은 아니다. &lt;/p&gt;
&lt;p&gt;단, &lt;strong&gt;관측된 0.45~0.5초는 동기화 오차이지 별도로 측정한 seek 소요시간이 아니다.&lt;/strong&gt; 이를 seek 지연으로 해석한 것은 당시 AI의 가설이다. 보정 후 성공 응답은 있지만 실제 개선된 오차 수치는 보고되지 않았다.    &lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;리더 노트북에서 도는 로컬 서버가 중계 역할을 한다. 리더 화면은 200ms마다 재생 위치·상태를 서버로 보내고, 서버는 이를 SSE로 모든 뷰어 폰에 뿌린다. 뷰어는 이 값으로 &amp;quot;지금 리더가 몇 초를 재생 중인지&amp;quot;(&lt;code&gt;exp&lt;/code&gt;)를 추정한다.  &lt;/p&gt;
&lt;p&gt;뷰어의 음소거 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;는 이 추정 시각에 맞춰 보정된다. 자기 &lt;code&gt;currentTime&lt;/code&gt;을 &lt;code&gt;exp&lt;/code&gt;와 비교해 차이가 나면 재생 속도(&lt;code&gt;playbackRate&lt;/code&gt;)를 조금 바꾸거나 해당 시각으로 점프(seek)한다. 처음에는 이 보정이 화면 그리기 루프 안에서 초당 약 60회 돌았고, 도중에 220ms 간격으로 줄였다. 대형 오버레이는 매 프레임 그려지며, 영상이 켜져 있으면 &lt;code&gt;video.currentTime&lt;/code&gt;을 기준으로 그린다.  &lt;/p&gt;
&lt;p&gt;영상 공급 방식은 조사 중에 여러 번 바뀌었다. 원본 mp4를 통째로 받아 메모리(Blob)에서 재생하는 방식, 스트리밍, 그리고 화질을 기기별로 자동 조절하는 HLS까지 붙었다. 나중에는 참여 화면에서 &amp;quot;스트리밍&amp;quot;과 &amp;quot;미리 받기(Blob)&amp;quot;를 고르고, 영상 없이 동선만 보는 버튼도 생겼다.    &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;개발자는 아이폰 12 Pro에서 360p 스트리밍도 끊긴다고 했다. 더 구체적인 묘사는 &amp;quot;맞았다 싶으면 다시 끊겨서 느려지고&amp;quot;의 반복이었다. 미리 받아 재생해도 같았지만 영상 없이 동선만 표시하면 매끄러웠다.  &lt;/p&gt;
&lt;h2&gt;원인을 좁힌 과정&lt;/h2&gt;
&lt;h3&gt;두 번의 초기 진단은 해결로 이어지지 않았다&lt;/h3&gt;
&lt;p&gt;첫 진단은 렌더 루프였다. AI는 잦은 배속 변경, 레이아웃 크기 읽기, 반복되는 &lt;code&gt;preservesPitch&lt;/code&gt; 대입을 지목했다. 이를 정리하고 배속 변경에 문턱을 넣었다고 보고했지만, 개발자의 답은 &amp;quot;해결되지 못했네&amp;quot;였다. 최적화가 무의미했다는 증거는 아니지만, 이 변경으로 원인을 해결했다는 판단은 틀렸다.   &lt;/p&gt;
&lt;p&gt;두 번째는 큰 Blob의 메모리 부담이었다. AI는 109MB 영상과 청크 결합 과정의 메모리 사용을 근거로 &amp;quot;범인 확정에 가까워&amp;quot;라고 말하고 모바일을 스트리밍으로 바꿨다. 그러나 스트리밍 전환 뒤에도 개발자는 재생이 아쉽다고 했고, 나중에는 360p에서도 끊김을 보고했다. 메모리 프로파일 없이 내린 확신이 해결보다 먼저 나왔다.    &lt;/p&gt;
&lt;h3&gt;대조군이 지운 것은 &amp;#x27;확신&amp;#x27;이었다&lt;/h3&gt;
&lt;p&gt;중간에 만든 두 기능이 비교 조건이 됐다. 동선만 모드는 영상 소스를 분리하고 영상 보정을 건너뛰며 리더 추정 시각으로 동선을 그렸다. 미리받기 모드는 mp4를 받아 Blob으로 재생했다. 따라서 두 모드 모두 한 변수만 바꾼 엄밀한 실험은 아니었다.  &lt;/p&gt;
&lt;div class="table-wrap"&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;비교 조건&lt;/th&gt;&lt;th&gt;원본에서 확인한 결과&lt;/th&gt;&lt;th&gt;여기까지 말할 수 있다&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;스트리밍&lt;/td&gt;&lt;td&gt;360p에서도 끊김 보고&lt;/td&gt;&lt;td&gt;고화질 부담만으로 설명하기 부족하다. &lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;미리받기(Blob)&lt;/td&gt;&lt;td&gt;여전히 같은 문제&lt;/td&gt;&lt;td&gt;재생 중 영상 다운로드 지연만을 원인으로 삼기 어렵다. 동기화 통신까지 없앤 실험은 아니다.   &lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;동선만 표시&lt;/td&gt;&lt;td&gt;매끄러움&lt;/td&gt;&lt;td&gt;영상이 빠진 경로는 정상이다. 영상 재생과 보정 경로를 우선 조사할 근거가 된다.  &lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;여기서 &amp;quot;대역폭·메모리·디코딩을 모두 기각했다&amp;quot;고 쓰면 한 발 더 나간다. 영상 없는 모드로 디코더의 정상 여부를 검증할 수는 없다. 또한 AI가 직전에 14MB짜리 360p 파일을 전제로 미리받기를 권했지만, 미리받기는 활성 영상의 원본 mp4를 받는 구조였고 개발자는 실험 때 어떤 파일이었는지 확인해 주지 않았다. &lt;strong&gt;14MB에서도 재현됐으니 메모리는 무관하다는 결론 역시 이 기록으로 확정할 수 없다.&lt;/strong&gt;   &lt;/p&gt;
&lt;p&gt;실제로 반박된 것은 AI의 &amp;quot;미리받기라면 끊김이 사라진다&amp;quot;는 예측이었다. 두 대조군은 모든 하위 원인을 지우는 대신, 조사 우선순위를 영상 동기화 보정으로 옮겼다.   &lt;/p&gt;
&lt;h3&gt;보정을 약하게 해도, 강하게 해도 실패했다&lt;/h3&gt;
&lt;p&gt;배속 보정을 빼고 오차가 0.75초를 넘을 때만 seek하게 하자 개발자는 점프가 없어졌지만 보정이 안 된다고 했다. 다음에는 보정 시작과 종료에 서로 다른 기준을 두는 히스테리시스와 고정 ±6% 배속을 넣었고, AI의 시뮬레이션은 수렴했지만 실기기 응답은 &amp;quot;매우 끊겨&amp;quot;였다.     &lt;/p&gt;
&lt;p&gt;다시 배속 보정을 제거하고 seek 임계값을 0.35초로 내리자 계속 점프했다. 개발자는 이때 &amp;quot;차이가 벌어지는지 좁혀지는지&amp;quot;를 보여 달라고 했다. 임계값을 더 조정하기 전에 오차의 움직임을 보자는 전환이었다.  &lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;오차 추세를 계측하면서 보정 조건도 함께 바꿨다. 오차가 0.35초를 넘고 계속 벌어질 때만 seek하며, &lt;code&gt;!video.seeking&lt;/code&gt;으로 진행 중인 seek의 중복 요청을 막았다고 보고했다. &lt;code&gt;?debug=1&lt;/code&gt; 화면에는 리더와의 오차 &lt;code&gt;Δ&lt;/code&gt;, 오차 추세, 배속, seek 임계값을 표시했다. 개발자가 읽어 준 값은 &lt;strong&gt;0.45~0.5초 사이의 반복&lt;/strong&gt;이었다.  &lt;/p&gt;
&lt;p&gt;AI는 이를 &amp;quot;현재 리더 시각으로 seek해도, 완료될 때 리더가 이미 앞으로 가 있다&amp;quot;는 구조로 해석했다. 이 가정에서는 목표 시각을 그대로 따라가는 seek이 잔차를 남기고, 그 잔차보다 낮은 임계값은 재차 seek을 유발할 수 있다. 다만 직전 변경에는 추세 조건과 seek 진행 중 가드도 있었으므로, 이 설명만으로 당시 반복 seek의 정확한 발생 조건까지 확인된 것은 아니다. 약 0.47초 역시 관측 오차에서 추정한 값이다.  &lt;/p&gt;
&lt;p&gt;임계값을 1초로 올려 0.5초 뒤처짐을 그대로 두는 방안과, 동선을 영상 대신 리더 시각에 맞춰 그리는 방안도 나왔지만 개발자는 거절했다. 댄스 영상에서는 음악과 동작이 맞는 것이 중요하고, 0.5초는 큰 차이라는 요구였다.   &lt;/p&gt;
&lt;p&gt;최종 구현 보고는 다음과 같다. 아래는 세션에 남은 설명을 정리한 것이며 코드 원문을 복원한 것은 아니다.   &lt;/p&gt;
&lt;ol&gt;&lt;li&gt;&lt;code&gt;hardSeek&lt;/code&gt;의 목표를 &lt;code&gt;exp + seekComp&lt;/code&gt;로 앞당긴다. &lt;/li&gt;&lt;li&gt;&lt;code&gt;seekComp&lt;/code&gt;를 0.45초로 시작하고 착지 후 잔차로 갱신한다. &lt;/li&gt;&lt;li&gt;구현 보고의 표현대로 “착지측정 0.8초 후”를 두고, 쿨다운 1초로 연속 보정을 제한한다. 재동기화 임계값은 0.30초로 둔다. &lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;이 변경 뒤 개발자는 &amp;quot;됐다!&amp;quot;고 답했고, 이후에는 동선만 보는 버튼을 없애도 되겠다고 요청했다. 이것이 기록에 남은 성공 확인이다. AI가 제시한 &lt;strong&gt;0.5초→0.05초는 시뮬레이션 결과&lt;/strong&gt;이며 실기기 측정 결과가 아니다.   &lt;/p&gt;
&lt;p&gt;이 사례에서 가져갈 순서는 &lt;strong&gt;비교 모드로 조사 범위를 좁히기 → 오차 추세를 표시하기 → 보정 후 잔차에 맞춰 목표 시각을 바꾸기&lt;/strong&gt;다. 대조군은 범인을 즉시 확정해 주지 않았다. 대신 틀린 예측을 드러냈고, 다음에 무엇을 측정할지 정해 줬다.    &lt;/p&gt;
&lt;h2&gt;취재 후기 — 손잡이보다 계기판&lt;/h2&gt;
&lt;p&gt;*아래 대사는 세션의 관찰과 진단을 바탕으로 재구성한 것이다.*&lt;/p&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;우리 무용 연습실 웹앱 있잖아. 리더 노트북이 음악을 틀면 다들 자기 폰으로 같은 안무 영상이랑 대형을 맞춰 보는 거. 그런데 내 아이폰 12 Pro에서만 영상이 뚝뚝 끊겨.     &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;음악은 연습실 스피커 하나로 나가고, 폰 영상은 음소거라 타이밍만 따라가면 돼. 리더가 0.2초마다 재생 위치를 보내면, 폰은 자기 영상 시각과 비교해서 재생 속도를 바꾸거나 그 지점으로 건너뛰어.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;화면 그리는 루프가 너무 바쁜 건 아닐까? 초당 60번 도는 루프에서 매번 재생 속도(&lt;code&gt;playbackRate&lt;/code&gt;)를 조금씩 바꾸고 화면 크기까지 읽고 있거든. 잦은 속도 변경이 끊김을 만드는지 의심돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;맞는지는 걷어내 보면 알아. 속도는 0.015 이상 차이 날 때만 바꾸고, 크기 읽기와 &lt;code&gt;preservesPitch&lt;/code&gt;(속도를 바꿔도 음높이를 유지하는 설정) 재설정은 루프에서 빼자. 이게 원인이면 같은 폰에서 끊김이 사라져야 해. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;고친 버전을 같은 폰에서 다시 틀어 봤는데, 끊기는 건 그대로야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이 수정만으로는 해결되지 않았어. 루프 부담이 일부 영향을 줬는지까지는 모르지만, 수상한 코드를 고쳤다는 이유만으로 증상을 해결했다고 할 수는 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 메모리가 의심돼. 109MB 영상을 조각으로 받아서 Blob, 그러니까 통째로 받은 영상 데이터로 합치고 있어. 합치는 동안 메모리 부담이 커진다는 가설은 어때? &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;폰에서는 통째로 받지 말고 스트리밍으로 틀어 보자. 그래도 끊기면 큰 Blob만 원인으로 지목할 수는 없어. 이 비교만으로 메모리 영향을 전부 배제할 수는 없고. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;스트리밍으로 바꿔도 여전히 아쉬워. 나중에 화질을 360p까지 낮춰서 스트리밍했는데도 끊길 때가 있어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;큰 파일이 있다는 근거만으로 메모리 원인이라고 확신할 수는 없어. 메모리 측정 결과도 없고, 스트리밍 전환 뒤에도 증상이 남았으니 다른 경로를 살펴보자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 스트리밍의 점프가 범인이야. 리더와 벌어져서 건너뛰기(seek)를 하면 스트리밍은 그 지점을 새로 받느라 멈칫하거든. 미리 다 받아 두면 건너뛰기가 즉시라 안 끊길 거야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 두 갈래로 나눠 보자. 참여할 때 &amp;#x27;미리 받기&amp;#x27;를 골라 Blob으로 재생해 보고, 영상을 아예 끄고 동선만 리더 시각에 맞춰 그리는 모드도 켜 봐.   &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;미리 받아도 똑같이 끊겨. 그런데 동선만 켜면 매끄러워. 영상은 리더와 맞았다 싶으면 다시 끊기고 느려지는 걸 계속 반복해. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;미리 받으면 해결된다는 예측은 틀렸어. 영상 쪽을 봐야 한다는 건 알았지만, 영상을 끈 모드로 디코더까지 무죄라고 할 수는 없어. 미리 받은 게 어느 파일이었는지도 확인 못 했고.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;비교 모드가 범인을 찍어 준 건 아니야. 대신 틀린 예측 하나를 지우고, 다음에 볼 곳을 우리 동기화 보정 코드로 좁혀 줬어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 보정이 진동하는 거야. 속도를 올렸다 내렸다 하니 느려졌다 맞았다를 반복하지. 속도는 1배로 고정하고, 0.75초 넘게 벌어질 때만 건너뛰자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;점프는 없어졌어. 대신 리더랑 벌어진 채로 안 붙어. 기준이 0.75초라 보정이 아예 안 걸리는 것 같아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 0.15초 넘게 벌어지면 속도를 ±6%로 딱 한 번 바꾸고, 0.04초 안으로 붙으면 되돌리자. 계산으로 돌려 보니 속도는 두 번만 바뀌고 4초쯤이면 붙어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;폰에서는 매우 끊겨. 계산에서는 매끄럽게 붙는다던 게 실제 폰에서는 완전히 달라. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이 실험에서는 배속 보정을 넣자 심하게 끊겼어. 비1배속 자체가 문제인지는 아직 가설이지만, 일단 배속 보정은 빼 보자. 속도는 다시 1배로 고정하고, 건너뛰는 기준만 0.35초로 낮추자.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번엔 쉬지 않고 계속 점프해. 오차가 벌어지는 건지 좁혀지는 건지 눈으로는 모르겠어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;기준값만 세 번 돌렸지, 보정한 뒤에 오차가 어디에 머무는지는 한 번도 안 봤어. 계기판 없이 손잡이만 돌리면 맞춰도 왜 맞았는지 모르고 끝나. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;주소 뒤에 &lt;code&gt;?debug=1&lt;/code&gt;을 붙이면 화면 아래에 리더와의 오차 &lt;code&gt;Δ&lt;/code&gt;, 오차가 벌어지는지 좁혀지는지, 속도, 점프 기준이 뜨게 하자. 동시에 오차가 벌어질 때만 점프하고, 이미 점프 중이면 다시 요청하지 않게 바꿨어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;떴어. &lt;code&gt;Δ&lt;/code&gt;가 0.45에서 0.5초 사이를 계속 왔다 갔다 해. 더 커지지는 않고 거기서 맴돌아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 이렇게 설명돼. 리더의 지금 시각으로 건너뛰면, 건너뛰기가 끝날 즈음 리더는 이미 0.47초쯤 앞에 가 있어. 잔차가 기준 0.35보다 크면 다시 건너뛰려 할 수 있겠지. 다만 방금 넣은 추세 조건까지 고려한 발생 기록은 더 필요해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 0.47은 건너뛰는 데 걸린 시간을 직접 잰 게 아니라, 머무는 오차에서 거꾸로 추정한 값이야. 기준을 1초로 올려 0.5초 뒤처짐을 그냥 두는 방법도 있긴 해.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;춤은 음악과 동작이 맞는 게 제일 중요해. 동선은 좀 어긋나도 되지만, 영상이 0.5초 늦는 건 댄스에선 꽤 큰 차이라 그냥 둘 순 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 목표를 앞당기자. 리더의 추정 시각 &lt;code&gt;exp&lt;/code&gt;로 건너뛰지 말고, 늦게 착지하는 만큼을 미리 더한 &lt;code&gt;exp + seekComp&lt;/code&gt;로 건너뛰는 거야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;늦게 착지하는 양은 기기나 상황마다 다를 텐데, 0.45초를 박아 두면 다른 폰에서 또 어긋나지 않을까?&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그래서 고정값이 아니야. &lt;code&gt;seekComp&lt;/code&gt;는 0.45초에서 시작해 착지 뒤 남은 오차로 고쳐 가. 측정에는 0.8초 대기를 두었어. 점프 기준은 0.30초, 연속 점프는 1초 간격으로 막고. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;됐다! 이제 영상 없이 동선만 보는 버튼도 없애도 될 것 같아.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;0.5초가 0.05초로 줄었으니 열 배 개선이네. 이걸 실기기 개선 수치로 봐도 될까? &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그 0.05는 계산으로 돌려 본 값이야. 폰에서는 &amp;quot;됐다&amp;quot;까지만 확인했고, 그때 &lt;code&gt;Δ&lt;/code&gt;가 얼마였는지는 적어 두지 않았어.  &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;보정 기준값을 또 돌리고 싶어지면, 먼저 보정한 뒤 오차가 어디에 머무는지 화면에 띄워. 이번엔 &amp;#x27;0.5초 근처에 머문다&amp;#x27;는 숫자가 목표 시각을 앞당기는 실험으로 이어졌어.   &lt;/div&gt;&lt;/div&gt;</description></item><item><title>Claude Code를 안드로이드 태블릿에 붙이려는데, 음성비서 앱은 어디부터 만들까?</title><link>https://bug-casebook.pages.dev/posts/android-voice-assistant-mock-brain/</link><guid>https://bug-casebook.pages.dev/posts/android-voice-assistant-mock-brain/</guid><description>&lt;p&gt;PC에서 쓰던 개인 음성비서를 안드로이드 태블릿으로 옮기려 했다. PC 버전은 호출어(&lt;code&gt;hey_jarvis&lt;/code&gt;)를 듣고, 들은 문장을 &lt;code&gt;claude -p&lt;/code&gt;(Claude Code를 한 번 실행해 답만 받는 명령)에 넘기고, 받은 답을 음성으로 읽어주는 Windows용 Python 프로그램이다. 목표는 충전 거치해 둔 태블릿이 항상 귀를 열어두는 앱이었지만, Claude Code를 어디서 실행할지와 앱이 무엇을 맡을지가 뒤섞여 무엇부터 만들지 정리해야 했다. &lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;앱은 귀·입을 맡고, 답을 만드는 두뇌는 인터페이스 뒤로 분리한다. 첫 버전에는 실제 Claude 연결 대신 &lt;code&gt;MockBrain&lt;/code&gt;을 둔다.&lt;/strong&gt; 이 대화에서 정리한 출발점이다. &lt;code&gt;MockBrain&lt;/code&gt;은 들은 문장을 되돌려주거나 미리 정한 답을 내는 대역으로 제안됐다. &lt;/p&gt;
&lt;p&gt;두뇌가 태블릿 안(Termux)에 있든 PC 서버에 있든, 앱 입장에서는 &amp;quot;텍스트 한 줄 보내고 텍스트 한 줄 받기&amp;quot;로 똑같다. 그래서 귀·입 부분은 어느 쪽으로 가든 버려지지 않는 작업이다. &lt;/p&gt;
&lt;p&gt;제안된 첫 버전의 흐름은 다음과 같다. 실제 구현 코드가 아니라 설계 요약이다. &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;앱(Foreground Service): 마이크 상시 → 호출어 감지 → 음성을 텍스트로 변환
                     ↓
              BrainClient.ask(text)
                     ↓
              MockBrain의 정해둔 응답
                     ↓
앱: 응답 텍스트를 음성으로 읽기 + 상태·로그 표시
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 연결 후보는 &lt;code&gt;TermuxBrain&lt;/code&gt;과 &lt;code&gt;HttpBrain&lt;/code&gt;으로 남겼다. 다만 &lt;strong&gt;여기서 확인된 것은 설계 방향까지다.&lt;/strong&gt; 기록은 빌드·설치 환경을 정해야 한다는 답변으로 끝나며, 앱을 만들거나 태블릿에서 실행한 결과는 담겨 있지 않다. &lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;처음 계획은 태블릿이 호출어 감지·음성 인식(STT)·음성 출력(TTS)만 하고, 인식한 텍스트를 PC로 보내 답을 받는 구조였다. 그런데 PC를 거치지 않고 태블릿 안에서 Claude Code까지 돌리는 방향으로 요구가 바뀌면서 구조가 복잡해졌다. &lt;/p&gt;
&lt;p&gt;걸림돌은 Claude Code가 Node.js로 된 명령줄 프로그램이라는 점이다. 안드로이드 앱(APK)은 Java/Kotlin 세계라 &lt;code&gt;claude&lt;/code&gt; 실행 파일을 품을 수 없다. 그래서 안드로이드용 리눅스 터미널 앱인 Termux에 Node.js와 Claude Code를 설치하고, 직접 만든 네이티브 앱이 Termux에 &amp;quot;이 문장을 &lt;code&gt;claude&lt;/code&gt;에 넣고 결과를 달라&amp;quot;고 시키는 구조가 제안됐다. 앱이 Termux에 일을 시키는 방법으로는 &lt;code&gt;RUN_COMMAND&lt;/code&gt; 인텐트가 언급됐다. &lt;/p&gt;
&lt;p&gt;이렇게 나눈 책임은 두 가지다. 호출어 감지, STT, TTS, 화면은 앱 쪽에 두고, 질문 텍스트를 받아 응답 텍스트를 만드는 부분은 별도로 둔다. &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;이번 사건의 증상은 실행 오류가 아니라 구조에 대한 혼동이었다. 먼저 &amp;quot;termux 를 이용하면 앱 개발이 되는건 아닌셈인가?&amp;quot;라는 질문이 나왔고, 이어 앱이 &amp;quot;termux 에 돌고 있는 claude code&amp;quot;로 전달하는 것인지 되물었다. 앱 개발의 범위와, 무엇이 상시 대기하는지가 아직 분명하지 않았던 지점이다. &lt;/p&gt;
&lt;p&gt;전환점은 &amp;quot;난 앱이 좀 궁금한거라서&amp;quot;라는 말이었다. 듣고 말하는 것은 앱에서 하고 두뇌 처리 위치만 달라진다는 점을 이해한 뒤, 두뇌를 목킹하고 앱부터 올려볼 수 있는지로 요청이 좁혀졌다. &lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;대화를 가로막은 것은 &lt;strong&gt;듣고 말하는 앱을 만드는 일과 실제 답변 엔진을 연결하는 일을 한 덩어리로 생각한 것&lt;/strong&gt;으로 읽힌다. Termux는 앱 개발을 대신하는 것이 아니라, 앱이 혼자 못 하는 한 가지(Claude Code 실행)만 메우는 보조 장치다. 두 책임을 나누자 실제 엔진의 위치를 결정하지 않고도 앱부터 만들어보자는 요청이 나왔다. 이는 발화 흐름에 대한 해석이며, 실행 장애의 원인을 찾아낸 사례는 아니다. &lt;/p&gt;
&lt;p&gt;또한 이때 제안된 Termux 연결안에서 Claude Code는 계속 대기하는 서버가 아니었다. 평소에는 설치만 돼 있고, 앱이 문장을 넘길 때마다 &lt;code&gt;claude -p&lt;/code&gt;를 새로 실행하고 응답 후 끝내는 방식으로 설명됐다. 상시 음성 대기는 앱의 마이크가 맡는다. 이는 해당 설계안의 설명이지, Claude Code의 모든 실행 방식에 대한 단정은 아니다. &lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;해결은 구현 완료가 아니라 첫 개발 범위를 정한 것이었다. 두뇌를 &lt;code&gt;interface&lt;/code&gt;로 분리하고 지금은 &lt;code&gt;MockBrain&lt;/code&gt;, 나중에는 &lt;code&gt;TermuxBrain&lt;/code&gt; 또는 &lt;code&gt;HttpBrain&lt;/code&gt;을 연결하는 안이다. 앱은 구체적인 실행 위치 대신 질문과 응답을 주고받는 경계만 바라보도록 제안됐다. &lt;/p&gt;
&lt;p&gt;첫 앱에는 마이크를 상시 켜두는 Foreground Service, 호출어 감지(PC와 같은 openWakeWord &lt;code&gt;hey_jarvis&lt;/code&gt; 모델), &lt;code&gt;SpeechRecognizer&lt;/code&gt;를 이용한 음성 인식, &lt;code&gt;BrainClient.ask(text)&lt;/code&gt;, &lt;code&gt;TextToSpeech&lt;/code&gt;를 이용한 음성 출력, 현재 상태(대기중/듣는중/처리중)와 인식·응답 로그 화면이 제안됐다. 이 목록은 구현·검증된 구성표가 아니라 만들려던 범위다. &lt;/p&gt;
&lt;p&gt;설치 준비는 별도 과제로 남았다. 개발 PC를 확인한 결과 JDK는 16이라 최신 안드로이드 빌드에 필요한 17 이상을 새로 깔아야 하고, &lt;code&gt;adb&lt;/code&gt;, Android SDK, Android Studio, Gradle은 없다고 보고됐다. 앱 소스는 작성할 수 있어도 APK로 빌드해 태블릿에 올리려면 툴체인부터 갖춰야 한다. 원본의 환경 확인 명령 출력은 기록에 없으므로 여기서는 보고된 상태까지만 인용한다. &lt;/p&gt;
&lt;h3&gt;확인하지 못한 것&lt;/h3&gt;
&lt;p&gt;이 기록에는 태블릿에서 Claude Code를 실행한 결과, 앱 설치 결과, 상시 음성 감지 시험 결과가 없다. 따라서 &lt;code&gt;MockBrain&lt;/code&gt;으로 앱 검증이 성공했다거나 실제 엔진으로 교체해 동작했다고 결론 내릴 수 없다. &lt;/p&gt;
&lt;p&gt;&lt;code&gt;RUN_COMMAND&lt;/code&gt;와 &lt;code&gt;SpeechRecognizer&lt;/code&gt;도 각각 연결 방식과 음성 인식 후보로 언급됐을 뿐이다. 이 기사에서는 전자를 유일한 통신 경로로, 후자를 항상 로컬에서 동작하는 인식기로 단정하지 않는다. 해당 성질을 시험한 결과가 이번 기록에 없기 때문이다. &lt;/p&gt;
&lt;h2&gt;취재 후기 — 귀와 입부터 만들어보자는 결정&lt;/h2&gt;
&lt;p&gt;설계 대화를 네 사람의 작업 회의로 재구성했다. 실기기 재현 장면은 없고, 환경 관찰은 개발 PC 확인 결과로 제한했다. &lt;/p&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;PC에서 &amp;quot;헤이 자비스&amp;quot; 하면 듣고, &lt;code&gt;claude -p&lt;/code&gt;로 답을 받아 읽어주는 음성비서 있잖아. 이걸 충전 거치한 태블릿에서 항상 듣게 만들고 싶어. PC 없이 태블릿 혼자서 Claude Code까지 돌리는 걸로. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러려면 Termux가 필요해. 안드로이드에서 돌아가는 리눅스 터미널 앱인데, Claude Code는 Node.js 명령줄 프로그램이라 우리가 만드는 APK 안에는 넣을 수가 없거든. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;잠깐, 그럼 Termux에 깔면 끝이고 앱은 안 만들어도 되는 거야? 그냥 남의 터미널 앱을 빌려 쓰는 거잖아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;아니, Termux는 명령을 실행만 해. 마이크를 켜두고, 호출어를 듣고, 말을 글자로 바꾸고, 답을 소리로 읽고, 화면을 보여주는 건 전부 우리가 만들 앱 몫이야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러면 앱이 음성을 받고, Termux에서 계속 켜져 있는 Claude Code한테 넘기는 그림이지? 상시 감지라니까 두 쪽 다 켜두는 줄 알았어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;거기 하나만 달라. Claude Code는 평소엔 설치만 돼 있고, 앱이 문장을 넘길 때마다 &lt;code&gt;claude -p&lt;/code&gt;를 새로 실행해서 답이 나오면 끝나. 상시로 깨어 있는 건 앱의 마이크뿐이야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그러니까 헷갈린 이유는 귀를 열어두는 일과 질문에 답하는 일을 한 덩어리로 본 거네. 둘을 나누면, 답하는 쪽이 Termux든 PC 서버든 앱은 텍스트 한 줄 보내고 한 줄 받는 게 전부야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 내가 진짜 궁금한 건 듣고 말하는 앱이니까, 두뇌는 가짜로 두고 앱만 먼저 만들어서 태블릿에 올려보면 되겠다. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;맞아. 두뇌를 &lt;code&gt;interface&lt;/code&gt;, 그러니까 여러 구현이 똑같이 따르는 약속으로 빼두는 거야. 지금은 &lt;code&gt;MockBrain&lt;/code&gt;이 &amp;quot;○○라고 하셨네요&amp;quot;처럼 들은 말을 돌려주고, 나중엔 &lt;code&gt;TermuxBrain&lt;/code&gt;이나 PC에 붙는 &lt;code&gt;HttpBrain&lt;/code&gt;으로 바꿔 끼우면 돼. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;첫 앱 범위는 마이크를 붙잡아 두는 Foreground Service, 호출어, 음성을 글자로 바꾸는 STT, &lt;code&gt;BrainClient.ask(text)&lt;/code&gt;, 글자를 소리로 읽는 TTS, 그리고 상태·로그 화면이야. 어느 두뇌로 가든 이 부분은 안 버려져. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;올리기 전에 개발 PC부터 봤는데, JDK가 16이라 최신 안드로이드 빌드에 필요한 17 이상이 아니야. 기기에 앱을 설치하는 도구인 adb도, Android SDK·Android Studio·Gradle도 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 지금 판정은 설계 범위가 정해졌다는 데까지야. 빌드·설치 환경부터 갖춰야 하고, 태블릿에서 실제로 듣고 말하는지는 아직 확인하지 못했어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;어디서 돌릴지 못 정한 부분은 가짜로 막아두고, 어느 쪽으로 가든 남는 부분부터 만드는 거야. 다만 그렇게 정한 순서가 앱이 동작한다는 증거는 아니니까, 둘은 따로 확인해야 해. &lt;/div&gt;&lt;/div&gt;</description></item><item><title>안드로이드 앱에서 마이크를 계속 켜두려면? 부팅 자동 시작·SpeechRecognizer 오프라인까지 구현 전 체크리스트</title><link>https://bug-casebook.pages.dev/posts/android-always-listening-app-checklist/</link><guid>https://bug-casebook.pages.dev/posts/android-always-listening-app-checklist/</guid><description>&lt;p&gt;PC에서 호출어를 듣고 답을 읽어주던 개인 음성비서(voice-assistant)를, 충전 거치해 둔 안드로이드 태블릿에서 항상 듣는 앱으로 옮기려 했다.  구현 전에 &amp;quot;상시 마이크에 필요한 것&amp;quot; 목록부터 받았다.  그런데 공식 문서와 맞춰 보니 &lt;strong&gt;부팅 자동 시작&lt;/strong&gt;과 &lt;strong&gt;내장 음성 인식 = 로컬&lt;/strong&gt;이라는 두 항목이 그대로는 틀렸다.&lt;/p&gt;
&lt;blockquote&gt;이 글은 설계 단계의 체크리스트다. 실기기에서 돌려본 결과는 없고, 사실 판단은 모두 안드로이드 공식 문서(2026-09-23 확인)에 기댄다. 앱 구조(귀·입과 두뇌 분리, &lt;code&gt;MockBrain&lt;/code&gt;) 이야기는 &lt;a href="../android-voice-assistant-mock-brain/"&gt;Claude Code를 안드로이드 태블릿에 붙이려는데, 음성비서 앱은 어디부터 만들까?&lt;/a&gt;에 따로 정리했다.&lt;/blockquote&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;상시 음성 감지 앱을 만들기 전에 확인할 것:&lt;/p&gt;
&lt;div class="table-wrap"&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;항목&lt;/th&gt;&lt;th&gt;판정&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;android:foregroundServiceType=&amp;quot;microphone&amp;quot;&lt;/code&gt; 서비스 + &lt;code&gt;RECORD_AUDIO&lt;/code&gt;·&lt;code&gt;FOREGROUND_SERVICE&lt;/code&gt;·&lt;code&gt;FOREGROUND_SERVICE_MICROPHONE&lt;/code&gt; 선언&lt;/td&gt;&lt;td&gt;필수 (targetSdk 34 이상은 타입·타입별 권한 선언 의무)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;서비스는 &lt;strong&gt;화면에 보이는 Activity에서&lt;/strong&gt; 마이크 권한을 받은 뒤 시작&lt;/td&gt;&lt;td&gt;필수. &lt;code&gt;RECORD_AUDIO&lt;/code&gt;는 &amp;quot;사용 중에만&amp;quot; 권한이라 백그라운드에서 새로 시작하면 막힐 수 있다&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;로 부팅 직후 녹음 서비스 자동 시작&lt;/td&gt;&lt;td&gt;&lt;strong&gt;안 된다.&lt;/strong&gt; &lt;code&gt;BOOT_COMPLETED&lt;/code&gt; 수신기는 microphone 타입 FGS를 띄울 수 없다(Android 14부터). 부팅 후 앱을 한 번 여는 흐름을 전제로 설계&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;&lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;FGS 시작의 필수 조건은 아님. 거부하면 알림이 보이는 위치만 달라진다. 알림 자체는 FGS에 필요&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;배터리 최적화 예외·충전 거치·&lt;code&gt;WAKE_LOCK&lt;/code&gt;&lt;/td&gt;&lt;td&gt;도움은 되지만 &lt;strong&gt;무중단 보증은 아님.&lt;/strong&gt; 화면 잠금 상태 장시간 동작은 직접 측정&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;STT를 &lt;code&gt;SpeechRecognizer.createSpeechRecognizer()&lt;/code&gt;로&lt;/td&gt;&lt;td&gt;&lt;strong&gt;로컬 보장 아님.&lt;/strong&gt; 원격 서버로 오디오를 보낼 수 있다. 오프라인이 필요하면 API 31+의 온디바이스 인식기를 명시적으로 쓴다&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;7&lt;/td&gt;&lt;td&gt;호출어 감지(&lt;code&gt;AudioRecord&lt;/code&gt; 상시)와 STT가 마이크를 &lt;strong&gt;동시에&lt;/strong&gt; 쓰는가&lt;/td&gt;&lt;td&gt;보장 없음. 첫 구현은 &amp;quot;호출어 감지 → &lt;code&gt;AudioRecord&lt;/code&gt; 해제 → STT → TTS → 호출어 재개&amp;quot; 순차 전환으로&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;온디바이스 인식 쪽 뼈대는 이 정도다(API 31 이상).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;val recognizer = if (SpeechRecognizer.isOnDeviceRecognitionAvailable(context)) {
    SpeechRecognizer.createOnDeviceSpeechRecognizer(context)
} else {
    // 로컬 필수라면 여기서 조용히 일반 인식기로 넘어가지 말고 오류를 보여준다
    null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;isOnDeviceRecognitionAvailable()&lt;/code&gt;이 &lt;code&gt;true&lt;/code&gt;여도 한국어 모델이 준비됐다는 뜻까지는 아니다. API 33의 &lt;code&gt;checkRecognitionSupport()&lt;/code&gt;와 모델 다운로드 API로 언어별 상태를 따로 확인하고, &lt;strong&gt;네트워크를 실제로 끈 상태에서&lt;/strong&gt; 인식해 보는 것이 최종 판정이다. &lt;code&gt;RecognizerIntent.EXTRA_PREFER_OFFLINE=true&lt;/code&gt;는 구현에 따라 무시될 수 있어 오프라인 보증으로 쓰면 안 된다. (&lt;a href="https://developer.android.com/reference/android/speech/SpeechRecognizer"&gt;SpeechRecognizer&lt;/a&gt;, &lt;a href="https://developer.android.com/reference/android/speech/RecognizerIntent#EXTRA_PREFER_OFFLINE"&gt;RecognizerIntent&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;p&gt;만들려던 앱의 구조는 이렇다. Foreground Service(사용자에게 알림을 띄운 채 계속 도는 서비스)가 마이크를 붙잡고, openWakeWord 호출어 모델(PC 버전과 같은 &lt;code&gt;hey_jarvis&lt;/code&gt;)이 &amp;quot;헤이 자비스&amp;quot;를 감지하면 안드로이드 &lt;code&gt;SpeechRecognizer&lt;/code&gt;로 말을 텍스트로 바꾸고, 답을 &lt;code&gt;TextToSpeech&lt;/code&gt;로 읽는다. 답을 만드는 부분은 일단 &lt;code&gt;MockBrain&lt;/code&gt;이라는 가짜 구현으로 둔다. &lt;/p&gt;
&lt;p&gt;기기는 충전기에 꽂아 둔 전용 태블릿으로 쓰기로 했고, 그래서 배터리 문제는 대부분 사라진다고 봤다. &lt;/p&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;p&gt;구현 전에 받은 준비 목록은 다음과 같았다. &lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;code&gt;microphone&lt;/code&gt; 타입 Foreground Service + 상시 알림 (&amp;quot;Android 14+에서 타입 명시 필수&amp;quot;)&lt;/li&gt;&lt;li&gt;권한 &lt;code&gt;RECORD_AUDIO&lt;/code&gt;, &lt;code&gt;FOREGROUND_SERVICE&lt;/code&gt;, &lt;code&gt;FOREGROUND_SERVICE_MICROPHONE&lt;/code&gt;, &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;&lt;/li&gt;&lt;li&gt;배터리 최적화 예외, 거치 전용기라면 &lt;code&gt;WAKE_LOCK&lt;/code&gt;까지 걸면 Doze(화면이 꺼진 기기를 절전시키는 모드) 문제는 &amp;quot;사실상 사라진다&amp;quot;&lt;/li&gt;&lt;li&gt;부팅 자동 시작은 &lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;&lt;/li&gt;&lt;li&gt;STT는 내장 &lt;code&gt;SpeechRecognizer&lt;/code&gt; — &amp;quot;무료·로컬. 온디바이스 인식 켜면 오프라인도 가능&amp;quot;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;목록만 보면 선언 몇 줄로 끝날 것 같다. 이 목록 그대로 앱을 짜면 부팅 후 녹음이 시작되지 않거나, 오프라인이라고 믿은 인식이 실은 서버로 오디오를 보내는 상황을 만날 수 있다.&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;목록의 항목들이 &lt;strong&gt;&amp;quot;선언하면 된다&amp;quot;와 &amp;quot;언제 시작하느냐에 따라 막힌다&amp;quot;를 구분하지 않았다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 부팅 자동 시작.&lt;/strong&gt; 적법하게 시작된 microphone FGS가 백그라운드로 내려간 뒤에도 녹음을 이어가는 것과, 백그라운드에서 &lt;strong&gt;새로&lt;/strong&gt; 시작하는 것은 다르다. &lt;code&gt;RECORD_AUDIO&lt;/code&gt;는 while-in-use(앱을 쓰는 동안만) 제한을 받는 권한이고, 백그라운드 FGS 시작 예외와 마이크 권한 예외는 별개다. Android 14 이상을 대상으로 하는 앱이 이 조건을 어기고 microphone FGS를 올리면 &lt;code&gt;SecurityException&lt;/code&gt;이 날 수 있다. 부팅 브로드캐스트를 받은 시점의 앱은 화면에 보이는 Activity가 없다. 실제로 Android 15 동작 변경 문서는 &lt;code&gt;BOOT_COMPLETED&lt;/code&gt; 수신기가 띄울 수 없는 FGS 타입 목록에 &lt;code&gt;microphone&lt;/code&gt;을 넣고, 이 제한이 Android 14부터 있었다고 적어 두었다. (&lt;a href="https://developer.android.com/develop/background-work/services/fgs/service-types#microphone"&gt;FGS 타입&lt;/a&gt;, &lt;a href="https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start"&gt;백그라운드 시작 제한&lt;/a&gt;, &lt;a href="https://developer.android.com/about/versions/15/behavior-changes-15"&gt;Android 15 동작 변경&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 내장 STT = 로컬.&lt;/strong&gt; 기본 &lt;code&gt;createSpeechRecognizer()&lt;/code&gt;는 로컬 전용이 아니다. 공식 레퍼런스는 이 인식기가 원격 서버로 오디오를 보낼 수 있고, 지속 인식 용도로 설계되지 않았다고 적고 있다. 온디바이스 인식은 API 31부터 &lt;code&gt;isOnDeviceRecognitionAvailable()&lt;/code&gt;로 확인하고 &lt;code&gt;createOnDeviceSpeechRecognizer()&lt;/code&gt;로 따로 만들어야 한다. (&lt;a href="https://developer.android.com/reference/android/speech/SpeechRecognizer"&gt;SpeechRecognizer&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 작은 교정 두 가지.&lt;/strong&gt; &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;는 알림 표시 권한이지 FGS 시작 조건이 아니다. (&lt;a href="https://developer.android.com/develop/ui/compose/notifications/notification-permission"&gt;알림 권한&lt;/a&gt;) 그리고 microphone 타입 자체는 Android 11의 카메라·마이크 FGS 요건에서 이미 등장한다. Android 14에서 바뀐 것은 targetSdk 34 이상 앱이 타입과 타입별 권한을 &lt;strong&gt;반드시 선언&lt;/strong&gt;해야 한다는 점이다. (&lt;a href="https://developer.android.com/about/versions/11/privacy/foreground-services"&gt;Android 11 변경&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 목록에 없던 것 — 마이크 동시 사용.&lt;/strong&gt; 호출어 감지는 &lt;code&gt;AudioRecord&lt;/code&gt;로 마이크를 계속 열어 두고, &lt;code&gt;SpeechRecognizer&lt;/code&gt;는 자기 마이크 입력을 따로 연다. Android 10 이후 입력 공유 정책에서는 두 일반 앱이 동시에 실제 오디오를 받지 못하고 한쪽이 무음을 받을 수 있다. 같은 앱의 &lt;code&gt;AudioRecord&lt;/code&gt;와 특정 인식 서비스 조합이 항상 실패한다고까지는 단정할 수 없지만, 동시에 된다는 보장도 없다. 오디오 포커스는 &lt;strong&gt;출력&lt;/strong&gt; 중재라 이 문제를 풀어주지 않는다. (&lt;a href="https://developer.android.com/media/platform/sharing-audio-input"&gt;오디오 입력 공유&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;구현 전에 체크리스트를 이렇게 고쳐 쓴다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;시작 흐름&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;앱 화면(Activity)에서 &lt;code&gt;RECORD_AUDIO&lt;/code&gt; 런타임 권한을 받는다 → 같은 화면에서 서비스를 시작하고 &lt;code&gt;startForeground()&lt;/code&gt;에 &lt;code&gt;ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE&lt;/code&gt;을 넘긴다&lt;/li&gt;&lt;li&gt;부팅 후에는 &amp;quot;앱을 한 번 열어야 듣기 시작&amp;quot; 을 기본 동작으로 둔다. 부팅 수신은 알림으로 &amp;quot;눌러서 듣기 시작&amp;quot; 을 안내하는 정도로 쓴다&lt;/li&gt;&lt;li&gt;알림은 FGS에 필요하므로 만든다. &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;는 받으면 좋지만 없다고 서비스가 안 뜨는 건 아니다&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;계속 듣기&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;배터리 최적화 예외·충전 거치는 켜 두되, 화면 잠금 상태에서 1시간 → 8시간 순으로 호출어 탐지가 살아 있는지 로그로 잰다. 충전 여부·발열·중단 시각을 같이 남긴다&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;STT&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;온디바이스 인식기를 명시적으로 만들고, 한국어 모델 상태를 확인한다&lt;/li&gt;&lt;li&gt;로컬 필수 모드에서 온디바이스를 못 쓰면 &lt;strong&gt;오류로 보여준다.&lt;/strong&gt; 일반 인식기로 조용히 넘어가면 &amp;quot;오프라인으로 됐다&amp;quot; 는 착각이 생긴다&lt;/li&gt;&lt;li&gt;비행기 모드에서 같은 문장들을 인식시켜 성공 수·오류 코드를 따로 집계한다&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;마이크 전환&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;첫 비교 기준은 순차 전환: 호출어 감지 → &lt;code&gt;AudioRecord&lt;/code&gt; 캡처 중지·해제 → STT → &lt;code&gt;MockBrain&lt;/code&gt; 응답·TTS → 호출어 재개&lt;/li&gt;&lt;li&gt;STT의 결과·오류·취소 &lt;strong&gt;모든 경로&lt;/strong&gt;에서 호출어 감지로 돌아오게 하고, 재진입을 막는다&lt;/li&gt;&lt;li&gt;전환 사이 첫 음절이 잘리는지는 측정 대상으로 남긴다&lt;/li&gt;&lt;li&gt;API 33의 &lt;code&gt;RecognizerIntent.EXTRA_AUDIO_SOURCE&lt;/code&gt;로 이미 열어 둔 오디오를 인식기에 넘기는 방법도 있지만, 지원하지 않는 인식기는 자기 마이크를 연다. 쓰는 인식기가 지원하는지부터 확인한다 (&lt;a href="https://developer.android.com/reference/android/speech/RecognizerIntent#EXTRA_AUDIO_SOURCE"&gt;EXTRA_AUDIO_SOURCE&lt;/a&gt;)&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;툴체인&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;이 목록을 확인하려면 먼저 빌드·설치 환경이 있어야 한다. 이번 개발 PC는 JDK 16뿐이라 최신 안드로이드 빌드에 필요한 17 이상을 새로 깔아야 했고, &lt;code&gt;adb&lt;/code&gt;·Android SDK·Android Studio·Gradle이 없었다. &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;덤 — &amp;quot;거치 전용기라 배터리 걱정 없음&amp;quot;의 범위&lt;/h2&gt;
&lt;p&gt;충전기에 꽂아 두면 배터리가 닳는 문제는 사라진다.  하지만 이 글의 1~4번 문제(시작 시점 제한, 인식기의 서버 전송, 마이크 공유)는 전원과 무관하다. &amp;quot;거치 전용&amp;quot;은 배터리 항목 하나만 지워 주는 조건으로 읽는 게 안전하다.&lt;/p&gt;
&lt;h2&gt;취재 후기 — 선언 몇 줄이면 끝날 줄 알았던 목록&lt;/h2&gt;
&lt;blockquote&gt;*실기기 재현은 아직 없다. 네 사람은 받은 준비 목록을 공식 문서와 한 줄씩 맞춰 봤다.*&lt;/blockquote&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;PC에서 &amp;quot;헤이 자비스&amp;quot; 하면 듣고 답을 읽어주는 음성비서 있잖아. 그걸 충전기에 꽂아 둔 태블릿에서 항상 듣게 만들려고 준비 목록부터 받아 왔어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;목록 보니까 간단하던데. 마이크 타입 Foreground Service, 그러니까 알림을 띄운 채 계속 도는 서비스 하나에 권한 네 개, 부팅 자동 시작은 &lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;. 선언만 하면 전원 켜자마자 듣겠지. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;선언과 시작 시점은 따로 봐야 해. 백그라운드에서 마이크 서비스를 새로 띄우는 게 허용되는지, 공식 문서의 백그라운드 시작 제한 항목부터 확인하자.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;찾아봤어. &lt;code&gt;RECORD_AUDIO&lt;/code&gt;는 앱을 쓰는 동안만 유효한 권한이라, 화면에 앱이 없는 상태에서 마이크 서비스를 새로 만들면 Android 14 조건에서는 &lt;code&gt;SecurityException&lt;/code&gt;이 날 수 있대. 게다가 부팅 신호를 받는 수신기는 마이크 타입 서비스를 아예 못 띄운다고 목록에 박혀 있어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;부팅 직후엔 화면에 우리 앱이 없잖아. 그러니 부팅 수신만으로 녹음을 시작한다는 가설은 틀렸어. 앱을 한 번 열어서 시작하는 흐름이 기본이야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;계속 도는 것과 새로 시작하는 걸 한 줄로 묶은 게 실수였어. 안드로이드는 이미 켜진 마이크는 봐주지만, 뒤에서 몰래 새로 켜는 건 막는다고 생각하면 돼.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 STT는 문제없지. 내장 &lt;code&gt;SpeechRecognizer&lt;/code&gt;, 음성을 글자로 바꿔 주는 안드로이드 기본 부품이잖아. 무료고 기기 안에서 도니까 오프라인도 될 거야. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;&amp;quot;기기 안에서 돈다&amp;quot;가 근거 없는 전제야. 레퍼런스에 기본 인식기가 오디오를 어디로 보내는지 적혀 있을 테니 그걸로 가리자.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;기본 &lt;code&gt;createSpeechRecognizer()&lt;/code&gt;는 원격 서버로 오디오를 보낼 수 있다고 적혀 있어. 기기 안에서만 돌리려면 API 31부터 있는 &lt;code&gt;createOnDeviceSpeechRecognizer()&lt;/code&gt;를 따로 써야 하고.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 판정은 &amp;quot;내장 = 로컬&amp;quot;이 아니라 &amp;quot;온디바이스를 명시해야 로컬&amp;quot;이야. 그것도 한국어 모델이 준비됐는지, 비행기 모드에서 실제로 되는지 봐야 끝나.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;여기서 제일 위험한 건 온디바이스가 안 될 때 일반 인식기로 조용히 넘어가는 코드야. 그러면 서버로 보내 놓고도 오프라인으로 됐다고 믿게 되거든.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;알림 권한 &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;도 목록에 있었으니 이것까지 받아야 서비스가 뜨는 거지? 안드로이드 13부터 알림 띄우려면 물어봐야 하잖아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;문서엔 그 권한이 서비스 시작 조건은 아니라고 나와. 거부하면 알림이 어디에 보이는지만 달라진대.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 필수 목록에서 빼고 권장으로 내리자. 알림 자체는 서비스에 필요하니까 만들긴 해야 하고.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;목록에 없던 것도 하나 걸려. 호출어는 &lt;code&gt;AudioRecord&lt;/code&gt;로 마이크를 계속 열고, 인식기도 자기 마이크를 연다며. 둘이 동시에 마이크를 잡으면 한쪽이 무음을 받는 거 아냐?&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;오디오 입력 공유 문서를 보니 일반 앱 둘이 동시에 실제 소리를 받지 못하고 한쪽이 무음을 받을 수 있대. 같은 앱 안의 조합이 항상 실패한다고까진 안 쓰여 있고.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;된다는 보장도 없다는 뜻이네. 첫 구현은 호출어가 잡히면 &lt;code&gt;AudioRecord&lt;/code&gt;를 놓고, 인식이 끝나거나 실패하면 다시 호출어로 돌아오는 순차 전환으로 가자.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;배터리는 충전 거치로 지워지지만, 시작 시점이나 마이크 공유는 전원과 상관없는 문제야. 조건 하나가 목록 전체를 해결해 주진 않아. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;확인하려고 개발 PC를 봤는데 JDK가 16이야. 최신 안드로이드 빌드는 17 이상이 필요하고, 기기에 설치하는 도구 &lt;code&gt;adb&lt;/code&gt;도 SDK도 Android Studio도 없어. &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그래서 오늘 판정은 문서로 고친 체크리스트까지야. 실제로 잠금 화면에서 몇 시간 듣는지, 첫 음절이 잘리는지는 앱을 올려서 로그로 재야 해.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;상시 음성 앱의 준비물은 &amp;quot;무엇을 선언하나&amp;quot;보다 &amp;quot;언제 시작하고, 소리가 어디로 가고, 마이크를 누가 쥐나&amp;quot;를 먼저 묻는 목록이어야 해.&lt;/div&gt;&lt;/div&gt;</description></item><item><title>AI 코딩 에이전트로 백그라운드 프로그램을 띄우면 몇 초 뒤 사라지는 이유 — 버그가 아니라 에이전트 셸의 프로세스 정리였다</title><link>https://bug-casebook.pages.dev/posts/agent-shell-background-process-disappears/</link><guid>https://bug-casebook.pages.dev/posts/agent-shell-background-process-disappears/</guid><description>&lt;p&gt;윈도우에서 늘 대기하다가 &amp;quot;Hey Jarvis&amp;quot;라고 부르면 말을 받아 적어 Claude Code에 넘기고 답을 읽어 주는 로컬 음성 비서(voice-assistant)를 쓰고 있었다.
어느 날 재부팅 뒤 비서가 떠 있지 않아서 AI 코딩 에이전트에게 원인을 찾게 했다. 그런데 에이전트가 직접 띄워 본 프로세스도 20초쯤 지나면 계속 사라졌다. 그래서 &amp;quot;떠도 바로 죽는 버그&amp;quot;처럼 보였다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;에이전트 셸에서 띄운 백그라운드 프로세스가 사라졌다고 해서 프로그램이 죽었다고 단정하면 안 된다.&lt;/strong&gt; 에이전트의 명령 실행 환경이 호출이 끝날 때 자기가 띄운 자식 프로세스를 함께 정리했을 수 있다. 이 사건에서는 같은 프로그램을 에이전트 셸에서 띄우면 사라졌지만, 셸 밖(WMI)에서 띄우니 30초 내내 살아 있었고 로그도 계속 쌓였다 .&lt;/p&gt;
&lt;p&gt;&amp;quot;떠서 죽는다&amp;quot;고 결론 내기 전에 다음 세 가지로 실행 환경 탓인지부터 가른다.&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;띄우는 호출과 확인하는 호출을 나눈다.&lt;/strong&gt; 띄우는 명령은 바로 반환시키고, 살아 있는지는 별도 호출에서 확인한다 &lt;/li&gt;&lt;li&gt;&lt;strong&gt;프로세스 목록만 보지 말고 로그가 늘어나는지도 본다.&lt;/strong&gt; 한 호출 안에서 로그 파일 크기와 프로세스 등장·소멸을 촘촘히 추적한다 &lt;/li&gt;&lt;li&gt;&lt;strong&gt;에이전트 셸의 프로세스 트리 밖에서 띄워 본다.&lt;/strong&gt; 거기서 살아남는다면 코드가 아니라 실행 환경 문제다 &lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;3번을 PowerShell로 하면 다음과 같다. 일반적인 예시이고, 세션에서 실행한 명령 원문은 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 에이전트 셸의 자식이 아니라 WMI(CIM)가 새 프로세스를 만든다
Invoke-CimMethod -ClassName Win32_Process -MethodName Create `
  -Arguments @{ CommandLine = &amp;#x27;cmd /c &amp;quot;C:\path\to\start_assistant.bat&amp;quot;&amp;#x27; }

# 다음 호출에서 따로 확인: 프로세스가 있고, 로그가 늘어나는가
Get-Process pythonw -ErrorAction SilentlyContinue
(Get-Item C:\path\to\data\main_log.txt).Length
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정확히 어떤 메커니즘(예: Windows job object)이 프로세스를 정리했는지는 이 사건에서 확인하지 못했다. 에이전트의 추정이었다 . 그래도 &lt;strong&gt;셸 안에서 띄웠을 때와 밖에서 띄웠을 때 결과가 다르다&lt;/strong&gt;는 사실만으로 가짜 실패를 걸러 낼 수 있었다.&lt;/p&gt;
&lt;h2&gt;상황&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;음성 비서는 venv의 &lt;code&gt;pythonw.exe&lt;/code&gt;(콘솔 창 없이 도는 파이썬)로 상주한다 &lt;/li&gt;&lt;li&gt;자동 시작은 시작 프로그램 폴더의 바로가기 &lt;code&gt;VoiceAssistant.lnk&lt;/code&gt;가 &lt;code&gt;start_assistant_hidden.vbs&lt;/code&gt;를 실행하고, 그 VBS가 배치(bat)를 띄우고, 배치가 &lt;code&gt;pythonw&lt;/code&gt;를 실행하는 구조다 &lt;/li&gt;&lt;li&gt;대시보드 서버도 &lt;code&gt;DashboardServer.lnk&lt;/code&gt;로 같은 방식으로 뜬다 &lt;/li&gt;&lt;li&gt;조사는 AI 코딩 에이전트가 자기 셸에서 PowerShell 명령을 실행하면서 진행했다 &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;증상&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;시작 프로그램 폴더에 &lt;code&gt;VoiceAssistant.lnk&lt;/code&gt;가 그대로 있는데 &lt;code&gt;pythonw.exe&lt;/code&gt;가 하나도 없었다. 비서도 대시보드도 돌지 않았다 &lt;/li&gt;&lt;li&gt;&lt;code&gt;main_log.txt&lt;/code&gt;의 마지막 기록은 8월 23일이었고 조사한 날은 8월 27일이었다 . PC는 그날 00:19에 재부팅됐고, 조사는 부팅 7분 뒤에 시작했다 &lt;/li&gt;&lt;li&gt;원인을 찾으려고 에이전트가 직접 띄워 봤더니 프로세스가 없고 로그에도 새 줄이 없었다 . bat/vbs 경로로 띄워도 25초가 지나도록 뜨지 않았다 . vbs로 띄우면 20초 뒤에 사라졌다 &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;진단에 쓰던 &lt;strong&gt;에이전트의 실행 환경이 자기가 띄운 프로세스를 정리하고 있었다.&lt;/strong&gt; 이 때문에 멀쩡한 프로그램이 죽는 것처럼 보였다.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;콘솔 &lt;code&gt;python.exe&lt;/code&gt;로 실행하면 45초가 지나도 죽지 않고 호출어 대기 루프까지 들어갔다. 장치 인식과 &amp;quot;준비 완료&amp;quot;도 확인됐다 . 코드는 정상이었다&lt;/li&gt;&lt;li&gt;실제 bat을 &lt;code&gt;cmd /c&lt;/code&gt;로 돌리자 40초 넘게 끝나지 않고 블록됐다. cmd가 pythonw가 끝나기를 기다린다는 뜻이니 pythonw는 살아 있었다 . 로그에도 &amp;quot;준비 완료. Hey Jarvis&amp;quot;가 찍혔다 &lt;/li&gt;&lt;li&gt;그런데 그 백그라운드 태스크가 끝나자 &lt;code&gt;cmd /c bat&lt;/code&gt;도 exit 255로 함께 끝났다 &lt;/li&gt;&lt;li&gt;띄우는 호출과 확인하는 호출을 나눠도 pythonw는 없었다 . WMI로 띄운 첫 시도도 18초 뒤 사라졌다 . 하지만 &lt;strong&gt;WMI로 &lt;code&gt;cmd /c bat&lt;/code&gt;을 띄우자 30초 내내 pythonw 2개가 살아 있었고 로그도 60615→60777바이트로 늘었다&lt;/strong&gt; &lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;한계도 있다. vbs 경로는 끝까지 깔끔하게 설명되지 않았다. 같은 세션에서 vbs를 띄운 한 번은 로그가 전혀 늘지 않았고 , 다른 한 번은 로그에 새 &amp;quot;준비 완료&amp;quot;가 찍힌 뒤 20초 만에 사라졌다 . 에이전트는 이것을 &amp;quot;wscript가 곧바로 종료되면서 정리된 것&amp;quot;이며, 부팅 때 explorer가 띄우는 실제 상황과는 다르다고 해석했다 . 이 해석도 검증된 것은 아니다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;비서와 대시보드는 설정을 바꾸지 않고, 에이전트 셸 밖에서 &lt;code&gt;pythonw&lt;/code&gt;를 창 없이 직접 띄워 되살렸다. 비서는 pid 19652로 대기 상태가 됐고, 대시보드는 로컬 포트 8787에서 HTTP 200을 돌려줬다 &lt;/li&gt;&lt;li&gt;부작용이 있었다. 이렇게 띄우면 bat의 출력 리다이렉트를 거치지 않으므로 &lt;code&gt;main_log.txt&lt;/code&gt;에 stdout이 쌓이지 않는다. 대화 기록(&lt;code&gt;conversation_log.jsonl&lt;/code&gt;)은 정상으로 남는다 &lt;/li&gt;&lt;li&gt;정작 &lt;strong&gt;부팅 때 자동 시작이 왜 안 됐는지는 이 세션에서 확정하지 못했다.&lt;/strong&gt; 확인한 것은 바로가기가 있고, 작업 관리자에서 비활성화되지 않았고(두 항목이 비활성 목록에 없었다), 코드·스크립트·장치 인식이 정상이라는 점까지다 . 에이전트는 &amp;quot;재현이 안 돼서 딱 잘라 말하긴 어렵다&amp;quot;고 적었고, 다음에도 안 뜨면 작업 스케줄러로 옮기자고 제안했다 &lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;덤 — 가짜 단서는 하나 더 있었다&lt;/h2&gt;
&lt;p&gt;pythonw 실행을 테스트하던 중 &lt;code&gt;error: unrecognized arguments: USB Audio&lt;/code&gt;가 떴다. 입력 장치 인자 &lt;code&gt;--device &amp;quot;KT USB Audio&amp;quot;&lt;/code&gt;가 공백에서 쪼개져 argparse가 죽은 것으로 보였다 . 하지만 실제 bat을 그대로 돌리자 정상이었다. 그 에러는 &lt;strong&gt;테스트용 PowerShell &lt;code&gt;Start-Process&lt;/code&gt; 명령의 인용부호 문제&lt;/strong&gt;였다 .&lt;/p&gt;
&lt;p&gt;두 가짜 단서의 뿌리는 같다. &lt;strong&gt;진단하려고 만든 실행 경로가 실제 실행 경로와 달랐다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;다음 날 같은 증상이 다시 나타났을 때, 다른 조사에서는 wscript로 VBS를 네 번 돌려 네 번 모두 실패하자 &amp;quot;부팅 실패를 그대로 재현했다&amp;quot;고 판단했다 . 그런데 바로 다음 실행은 같은 경로로 성공했다 . 그 조사에는 에이전트 셸 밖에서 wscript를 돌려 보는 테스트 기록이 없다. 그래서 그 &amp;quot;4/4 재현&amp;quot;이 실제 부팅 실패와 같은 원인인지, 셸의 프로세스 정리 때문인지는 구분되지 않는다. 이 부분은 두 조사를 대조한 판단이며 검증된 사실은 아니다.&lt;/p&gt;
&lt;h2&gt;취재 후기 — 범인은 우리 손에 든 돋보기였다&lt;/h2&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;윈도우에서 늘 대기하다가 &amp;quot;Hey Jarvis&amp;quot; 하면 대답하는 음성 비서 있잖아. 재부팅하고 7분이 지났는데 &lt;code&gt;pythonw&lt;/code&gt;가 하나도 없어. 로그도 8월 23일에서 멈췄어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;시작 프로그램에서 빠진 거 아냐? 바로가기가 지워지면 부팅할 때 아무것도 안 뜨잖아.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;시작 프로그램 폴더를 열어 봤는데 &lt;code&gt;VoiceAssistant.lnk&lt;/code&gt;가 그대로 있어. 가리키는 곳도 숨김 실행용 VBS 그대로고 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 &amp;#x27;안 떴다&amp;#x27;인지 &amp;#x27;떴다가 죽었다&amp;#x27;인지부터 가르자. 우리가 직접 한 번 띄워 보면 알 수 있어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;내 셸에서 띄워 봤는데 프로세스가 없어. 로그에 새 줄도 안 붙었어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 떠서 바로 죽는 거네. 코드 어딘가에서 예외가 터지는데, 콘솔 창이 없는 &lt;code&gt;pythonw&lt;/code&gt;라 에러가 안 보이는 거야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그건 콘솔 창이 있는 &lt;code&gt;python.exe&lt;/code&gt;로 돌려 보면 가려져. 죽는다면 에러가 화면에 찍힐 테니까.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;콘솔로는 45초가 지나도 안 죽어. 마이크 장치도 잡았고 &amp;quot;준비 완료&amp;quot;까지 떴어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;코드 문제는 아니네. 남는 차이는 &lt;code&gt;pythonw&lt;/code&gt;로 띄우는 bat·vbs 경로야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;&lt;code&gt;Start-Process&lt;/code&gt;로 &lt;code&gt;pythonw&lt;/code&gt;를 띄웠더니 &lt;code&gt;unrecognized arguments: USB Audio&lt;/code&gt;가 나왔어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이거다. 장치 이름 &amp;quot;KT USB Audio&amp;quot;가 공백에서 쪼개져서 argparse, 그러니까 파이썬 명령줄 인자 해석기가 &amp;quot;USB Audio&amp;quot;를 모르는 인자로 보고 죽은 거야.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;잠깐, 그 인자는 우리가 테스트 명령에 옮겨 적은 거잖아. 진짜 bat을 &lt;code&gt;cmd /c&lt;/code&gt;로 그대로 돌려 보면 갈려.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;bat을 돌렸더니 40초가 넘도록 명령이 안 끝나. 로그에는 &amp;quot;준비 완료. Hey Jarvis&amp;quot;가 찍혔어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;cmd가 안 끝난다는 건 그 밑에서 pythonw가 살아 있다는 뜻이야. 인자 에러는 우리 테스트 명령의 따옴표 문제였어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;진단하려고 만든 명령이 실제 경로와 다르면, 그 차이가 새로운 버그처럼 보여. 재현은 원래 실행 경로 그대로 해야 해.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번엔 부팅 때처럼 vbs로 띄웠어. 20초 뒤에 사라지더라. 아까 bat도 내 백그라운드 작업이 끝나니까 exit 255로 같이 죽었고 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;이번엔 내가 조심할게. 우리 셸이 명령이 끝날 때 자기가 띄운 자식 프로세스까지 같이 치우는 거 아닐까? 부팅 때는 explorer가 부모라서 그럴 일이 없고.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 띄우는 호출은 바로 끝내고, 살아 있는지는 다른 호출에서 보자. 그래도 없으면 우리 셸의 프로세스 트리 밖, WMI로 띄워 본다.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;호출을 나눠도 없었어. WMI로 처음 띄운 것도 18초 뒤에 사라졌고 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그런데 WMI로 &lt;code&gt;cmd /c bat&lt;/code&gt;을 띄우니까 30초 내내 pythonw 두 개가 살아 있어. 로그도 60615에서 60777바이트로 늘었어 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;셸 밖에서는 살고 안에서는 죽는다. 그동안 본 &amp;#x27;떠서 죽는다&amp;#x27; 상당수는 우리 실행 환경이 만든 거였어. 다만 어떤 장치가 프로세스를 치웠는지는 아직 확인 못 했어.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-bada.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정바다&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 부팅 때 안 뜬 건? 작업 관리자에서 &amp;#x27;사용 안 함&amp;#x27;으로 꺼져 있으면 바로가기가 있어도 이 증상이 나잖아.&lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-doeun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도은&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;레지스트리를 봤는데 두 바로가기 모두 비활성 목록에 없어. 꺼져 있는 건 다른 프로그램 하나뿐이야 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/jung-haneul.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;정하늘&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;그럼 이번 부팅에서 자동 시작이 그냥 실행되지 않았다는 데까지만 말할 수 있어. 왜 그랬는지는 재현이 안 돼서 확정 못 해 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;다음 날 &amp;quot;wscript로 네 번 돌려 네 번 다 실패&amp;quot;했다는 재현도 셸 밖에서 돌려 본 기록이 없어. 바로 다음 판엔 성공했고 &lt;/div&gt;&lt;/div&gt;
&lt;div class="dialogue"&gt;&lt;div class="speaker"&gt;&lt;img class="avatar" src="../../assets/characters/park-dohyun.webp" alt="" width="48" height="48" loading="lazy"&gt;&lt;span&gt;박도현&lt;/span&gt;&lt;/div&gt;&lt;div class="speech"&gt;자동화 셸에서 띄운 프로세스가 사라지면, 프로그램을 의심하기 전에 그 셸 밖에서 한 번 더 띄워 봐. 돋보기가 증거를 지우고 있을 수도 있으니까.&lt;/div&gt;&lt;/div&gt;</description></item></channel></rss>