모바일 브라우저(웨일)에서만 안 되는데 콘솔을 볼 수가 없을 때 — ?debug=1 화면 디버그 패널
무용 연습실에서 각자 준비한 영상을 리더의 조작에 맞춰 함께 보는 영상 동기화 웹앱을 만들고 있었다. 매번 노트북 서버를 켜는 번거로움을 줄이려고 클라우드로 옮겼는데, 안드로이드 웨일에서 영상을 골라도 방 만들기·참여 버튼이 켜지지 않았다.
결론
모바일 브라우저에서만 나는 버그를 추측으로 고치고 있다면, 멈추고 ?debug=1일 때만 화면에 뜨는 로그 패널부터 넣는다. 핵심은 네 가지다.
- 맨 먼저 로드되는 스크립트로 넣는다. 그래야 페이지 로드 시점에 터진 에러까지 잡힌다
- 첫 줄에 UA와 쓰는 API 지원 여부를 찍는다
window.onerror(와unhandledrejection)를 화면으로 보낸다- 복사 버튼을 단다. 사용자가 로그를 그대로 붙여 넣을 수 있게
이 사건에서는 첫날 가설 네 개를 추측으로 쫓으며 코드를 여러 번 고쳤는데도 풀리지 않았고, 5일 뒤에도 증상은 그대로였다 . 패널을 배포하자 사용자가 첫 로그를 보냈고, 그 로그 한 줄로 원인이 바로 나왔다. 패널 커밋부터 수정 커밋까지 약 6분 걸렸다 .
아래는 같은 구성을 재현한 예시 구현이다(원본 debug.js 코드가 아니다. 원본의 구성 요소는 에 나열돼 있다).
<!-- 페이지의 첫 번째 <script>. 다른 어떤 스크립트보다 먼저 -->
<script src="/debug.js"></script>
// debug.js — ?debug=1 일 때만 켜지는 화면 로그 패널 (예시)
(function () {
const on = new URLSearchParams(location.search).get('debug') === '1';
const lines = [];
let box;
function t() { return new Date().toISOString().slice(11, 23); }
function render() { if (box) box.textContent = lines.join('\n'); }
window.dbg = function (...args) {
if (!on) return;
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
lines.push(t() + ' ' + msg);
render();
};
if (!on) return;
// 1) 페이지 로드 중 에러도 잡도록 가장 먼저 등록
window.addEventListener('error', e =>
dbg('❌ window.error:', e.message, '@', (e.filename || '') + ':' + e.lineno + ':' + e.colno));
window.addEventListener('unhandledrejection', e =>
dbg('❌ unhandledrejection:', String(e.reason)));
// 2) 환경 한 줄: 가설을 한 번에 지우는 줄
dbg('=== debug on ===');
dbg('UA:', navigator.userAgent);
dbg('secureContext:', window.isSecureContext,
'| crypto.subtle:', !!(window.crypto && crypto.subtle),
'| Blob.arrayBuffer:', typeof Blob !== 'undefined' && 'arrayBuffer' in Blob.prototype,
'| File.text:', typeof File !== 'undefined' && 'text' in File.prototype);
// 3) 패널 + 복사 버튼 (body가 생긴 뒤 붙이기)
document.addEventListener('DOMContentLoaded', () => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;right:0;bottom:0;max-height:40vh;overflow:auto;' +
'background:#000c;color:#0f0;font:11px/1.4 monospace;z-index:99999;padding:6px';
const btn = document.createElement('button');
btn.textContent = '복사';
btn.onclick = () => navigator.clipboard.writeText(lines.join('\n'))
.then(() => btn.textContent = '복사됨', () => btn.textContent = '복사 실패');
box = document.createElement('pre');
box.style.cssText = 'margin:0;white-space:pre-wrap';
wrap.append(btn, box);
document.body.append(wrap);
render();
});
})();
그다음 의심 가는 파이프라인에 dbg()를 한 줄씩 끼운다. 이 사건에서는 파일 정보 → 길이 읽기 시작/완료 → 지문 계산 시작/완료 → 버튼 disabled 최종값 순서였다 .
chrome://inspect 원격 디버깅 | 화면 디버그 패널 (?debug=1) | |
|---|---|---|
| 준비물 | PC, USB 케이블, 폰의 개발자 옵션 → USB 디버깅 | 없음. URL에 ?debug=1만 붙이면 됨 |
| 보이는 것 | 콘솔·네트워크 등 진짜 DevTools 전부 | 직접 심은 로그 + 전역 에러만 |
| 버그가 난 사람의 폰에서 | 그 폰을 PC에 꽂아야 함 | 링크만 보내면 됨. 로그는 복사해서 받음 |
| 이 사건에서 | 시도하지 않음 | 첫 로그로 원인 확정 |
네트워크 요청까지 봐야 하면 chrome://inspect가 낫다. "어디서 죽었는지"만 알면 되는 경우라면 패널이 훨씬 빠르다.
상황
리더가 연습방을 열고 영상을 선택하면, 참가자는 방 목록에서 같은 방에 들어가 각자 미리 받은 영상 파일을 고른다. 서버는 영상 대신 동기화 신호를 전달하고, 참가자의 재생은 리더 조작을 따른다.
같은 영상인지 확인하는 지문 계산 헬퍼는 별도 스크립트였다. 파일 선택 뒤 길이 읽기와 지문 계산을 거쳐 버튼 상태를 정하고, 뷰어는 서버에서 방 정보를 받아 영상 대조에 쓴다. 따라서 버튼이 꺼져 있다는 관찰만으로 어느 단계가 고장 났는지는 알 수 없었다.
증상
- Cloudflare Workers로 띄운 웹앱. 안드로이드에서 영상 파일을 고르면 파일명은 보이는데 "참여"·"방 만들기" 버튼이 켜지지 않았다
- 뷰어 화면은 "방 정보를 불러오는 중"에서 멈췄다. 아이폰·데스크톱은 정상이었다
- 같은 폰이라도 일반 크롬에서는 됐고, 웨일 브라우저에서만 안 됐다
- 5일 뒤에도 같은 증상이 그대로였다
이 사건에서는 문제 기기의 콘솔 로그 없이 수정을 반복했고, 이후 모바일 디버깅 방법을 찾으면서 화면 패널을 넣었다.
원인
두 층위로 봐야 한다.
버그 자체의 원인. 패널이 찍은 첫 로그는 이랬다(배포 주소는 제거했다) .
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 'fingerprintVideo' of 'window.FormationFingerprint' as it is undefined. @ /leader?debug=1:172:13
파일을 고르기도 전, 페이지를 열자마자 에러가 났다. 별도 파일로 둔 영상 지문 헬퍼 fingerprint.js가 웨일에서만 로드되지 않았다. 그래서 인라인 스크립트가 맨 위 구조분해에서 죽었고, 그 아래의 파일 핸들러·WebSocket 연결·버튼 활성화 코드가 전부 등록되지 않았다 . 버튼이 안 켜진 것도, "방 정보를 불러오는 중"에서 멈춘 것도 이 에러 하나로 설명된다 .
파일 이름을 mediacheck.js로 바꾸자 웨일에서도 됐다 . 바꾼 것은 파일명과 참조뿐이고, 전역 이름 FormationFingerprint와 함수 이름은 그대로 뒀다 . 그래서 막힌 기준은 파일명(URL)이었다고 보는 게 자연스럽다. 다만 웨일의 어떤 기능이나 필터 목록이 막았는지는 확인하지 않았다. "추적 방지 기능이 fingerprint를 지문 추적 스크립트로 오인했다"는 설명은 에이전트의 추론이다 .
첫날 원인을 못 잡은 이유. 에러를 볼 수단이 없어서 증상만 보고 고쳤다. 5일은 계속 매달린 시간이 아니라 첫날(8월 25일) 시도와 다음 시도(8월 30일) 사이의 간격이다. 확인된 사실은 이 둘뿐이다.
- 1차에는 가설 네 개(길이 읽기 무한 대기, WebSocket 불통, 웨일의 WS 차단, 안드로이드 사진 선택 도구)를 차례로 추측했고, 그에 맞춰 코드를 여러 번 바꿨지만 증상은 그대로였다
- 2차에는 사용자가 "모바일 브라우저 콘솔이나 디버깅 도구에 접근할 방법이 있다면"이라고 제안했고, 거기서 패널로 방향이 바뀌었다
해결
1. 두 길 중 패널을 고른 이유
에이전트는 chrome://inspect 원격 디버깅과 앱 안 디버그 패널을 함께 제시했다. 원격 디버깅은 케이블과 설정이 필요했다. 패널은 웨일에서 설정 없이 바로 된다는 이유로 패널을 먼저 택했다 . 에이전트는 웨일도 원격 디버깅이 "대개 된다"고 했지만, 이 사건에서는 시도하지 않았으므로 여기서는 확인하지 않은 주장으로 둔다 .
2. 패널 구성
사용법 안내에 적힌 구성은 이렇다 .
- 켜는 방법: 리더는
.../leader.html?debug=1, 뷰어는 기존 쿼리 뒤에&debug=1 - 맨 위: UA, secureContext, crypto.subtle, Blob.arrayBuffer, File.text 지원 여부
- 파이프라인 단계: 파일 정보(이름/크기/타입) →
readDuration시작·완료·에러 →fingerprint시작·완료·에러 →setupBtn.disabled/go_btn.disabled최종값 - 하단 고정 초록색 패널 + "복사" 버튼
debug.js는 두 페이지 모두에 첫 번째 스크립트로 넣었다 . 이 사건에서 가장 중요했던 결정이 이것이다. 실제 에러는 파일 선택 파이프라인이 아니라 페이지 로드 시점에 났다. 패널이 앱 스크립트보다 먼저 떠 있고 window.error를 듣고 있었기 때문에 잡을 수 있었다 .
3. 첫 줄이 한 일
패널 설계 단계에서 에이전트는 로그를 읽는 기준을 미리 정해 뒀다. crypto.subtle: false면 웨일이 보안 API를 막는 것, size: 0이면 파일 바이트가 넘어오지 않는 것, 이런 식이다 . 실제 첫 줄은 네 항목이 모두 true였다 . 이 값은 해당 환경·API의 존재 여부를 보여줄 뿐, 실제 호출 성공까지 보장하지는 않는다. 이 사건에서 실행 중단 지점을 알려준 결정적 증거는 바로 아래의 window.error 한 줄이었다.
4. 로그를 받은 뒤
- 에이전트는 처음에 URL이
.html없는/leader라서 상대경로가 꼬였다고 의심했다 . 하지만 데스크톱에서/leader로 열어도 정상이어서 그 가설은 바로 버렸다 fingerprint.js→mediacheck.js로 이름을 바꾸고, 파일 헤더 주석에 이름을 바꾼 이유를 남겼다- 데스크톱에서 회귀가 없는지 확인한 뒤 커밋했다
- 사용자: "된다!! 됐어! 잘했다!"
- 패널은 지우지 않고 남겨서, 이후 모바일 문제에도 그대로 쓰기로 했다
덤 — 같은 사건의 곁가지 교훈
- 진단 코드는 앱 코드와 같은 배에 태우지 않는다. 1차 때도 뷰어 "함께 보기" 화면에 연결 상태를 띄우는 진단 표시를 넣었다 . 하지만 그 표시에 무엇이 떴는지는 보고되지 않았고, 사용자는 곧 "크롬은 되고 웨일은 안 된다"는 쪽으로 넘어갔다 . 페이지 스크립트가 로드 직후 죽는 상황이라면, 같은 스크립트 안에 든 진단 코드도 함께 죽는다. 그 진단 표시가 정확히 어느 스크립트에 있었는지는 세션에서 확인되지 않지만, 별도 파일로 먼저 로드되는 패널이 이 함정을 피한다는 점은 분명하다
- 배포 확인은 가능하면 브라우저로 한다. 패널 배포를 curl + grep으로 확인하다가
debug.js참조가 0개로 나왔다. 처음에는 엣지 캐시를 의심했다 . 다음에는 gzip 응답을 풀지 않은 탓으로 보고--compressed를 붙였지만 이번엔 0바이트가 나왔다 . 결국 브라우저로 열어서 정상 배포를 확인했다 . curl이 왜 꼬였는지는 끝내 확정하지 않았다 - 파일 선택기 삽질은 곁가지였다. 첫날
accept를 세 번 바꾼 수정은 웨일 증상을 풀지 못했다 . 원인을 찾은 뒤 에이전트는 확장자 필터가 안 됐던 것도fingerprint.js차단이 스크립트를 죽이고 있었기 때문이라고 보고,video/*같은 MIME 없이 확장자만 쓰는 필터를 되살렸다 . 되살린 필터가 웨일에서 잘 됐는지는 세션에 나오지 않는다 - 스크립트 파일명에
fingerprint를 쓰지 않는다. 이 사건에서는 이름만 바꾸니 해결됐다 . 다만 특정 단어를 피하면 모든 차단 문제를 예방할 수 있다는 뜻은 아니다. 여기서 확인한 해결은 이 파일의 이름 변경이다
취재 후기 — 버튼을 고치기 전에 첫 에러부터
loadedmetadata, 영상 길이 같은 기본 정보가 준비됐다는 신호가 안 오는 것 아닐까? 길이 읽기를 계속 기다리면 버튼도 켜지지 않을 거야. accept는 파일 입력에서 고를 형식을 지정하는 속성이야. 영상 형식 지정과 확장자 필터를 차례로 바꾸고, 그래도 안 되면 속성을 빼서 비교하자. ?debug=1 화면 패널을 먼저 넣자. 앱보다 먼저 읽히게 해서 시작할 때 나는 에러도 잡자. window.FormationFingerprint가 없어서 fingerprintVideo를 꺼낼 수 없다고 나와. .html 없는 /leader라서 별도 파일인 fingerprint.js를 찾는 상대경로가 어긋난 것 아닐까? /leader로 열어도 객체가 있어. 웨일에서 본 건 파일 선택 뒤의 실패가 아니라 페이지를 열자마자 그 객체가 없다는 에러야. mediacheck.js로 바꾸고 두 페이지의 참조도 바꿨어. 함수와 전역 객체 이름은 유지했고 데스크톱에서도 확인했어. 이제 웨일 결과를 보자.