dotgimon 제작기 16. 버튼 무반응과 렌더링 부하 추적
오전에 dotgimon 시뮬레이터의 A, B, C 버튼을 눌렀는데 메뉴 커서가 움직이지 않았다. 화면은 Skia라는 2D 그래픽 라이브러리로 그리고, 조작은 화면 아래 버튼 세 개로 하는 게임이었다. 버튼에 반응이 없으니 할 수 있는 일이 없었다.
작업 트리에는 Device.tsx, Lcd.tsx, useGame.ts, engine.ts, audio.ts, package.json 여섯 파일의 미커밋 변경이 있었다. 타입 검사는 통과했다. Expo Go 프로세스의 CPU가 약 101%인 것을 보고 입력 코드뿐 아니라 런타임 부하도 살펴봤다.
앱은 20시간 넘게 켜져 있었다
당시 ps와 footprint로 읽은 값은 다음과 같았다. CPU와 메모리 값은 조사 시점에 찍은 관찰값이었다.
| 항목 | 값 |
|---|---|
| 실행 경과 시간 | 20시간 38분 |
| 누적 CPU 시간 | 약 110분 |
| CPU 사용률 | 약 100% |
| RSS | 약 0.97~1.08GB |
| footprint 명령의 값 | 2,468MB |
RSS는 실제 상주 메모리 지표다. footprint와 같은 값으로 합쳐 해석하지는 않았다. 오래 켜 둔 프로세스가 CPU를 계속 쓰고 있어 JS가 요청을 처리하는지 확인했다.
Metro의 /json/list에서 디버거 WebSocket 주소를 받아 연결했다. 연결은 열렸지만 평가식을 보낸 뒤 15초 동안 응답이 없었다. POST /reload 요청에도 앱이 반응하지 않아 JS 쪽이 요청을 제때 처리하지 못하는 상황을 의심했다.
sample로 2초 동안 호출 스택을 수집했다. 당시 분석에서는 JS 스레드인 com.facebook.react.runtime.JavaScript와 Hermes 가비지 컬렉터의 hades가 바쁘게 일하고 있었다. 가비지 컬렉션(GC)은 쓰지 않는 메모리를 회수하는 작업이다. 이 관찰로 GC 부하를 의심했지만 누수나 버튼 무반응의 원인을 확정한 것은 아니었다.
재시작 직후에도 부하가 컸다
Expo Go를 종료한 뒤 다시 열자 저장한 펫 상태는 복구됐다. 실행 25초 시점에도 CPU는 60~70%, RSS는 약 600MB였다. 오래 실행한 시간만으로 설명하기는 어려웠다.
버튼과 같은 게임 함수 press('A')를 외부에서 호출했더니 메뉴 커서가 STATUS로 움직였다. 그 호출에서 게임 로직이 반응하는 것은 확인했다. 화면 터치가 전달되는 경로와 렌더링 부하는 별도로 살펴볼 필요가 있었다.
3초마다 진단 값을 받았다
게임 루프에 임시 비콘을 넣었다. 비콘은 상태를 주기적으로 보내는 진단 코드였다. requestAnimationFrame으로 도는 루프에서 3초마다 통계를 로컬 수집 서버에 보냈고, 서버 응답에 명령이 있으면 같은 버튼 함수를 실행했다. 직접 터치하기 어려운 조사 환경에서 입력도 이 경로로 넣었다.
// useGame.ts — 임시 진단 비콘 (__DBG_BEACON_START ~ END)
const hs = (globalThis as any).HermesInternal?.getInstrumentedStats?.() ?? {}
const body = JSON.stringify({
fps, avgLoop, maxLoop, maxGap,
dev: dbg.deviceRenders, lcd: dbg.lcdRenders,
heap: hs.js_heapSize, alloc: hs.js_allocatedBytes,
gcs: hs.js_numGCs, gcT: hs.js_gcTime, gcMax: hs.js_maxGCPause,
})
fetch('http://127.0.0.1:9999/', { method: 'POST', body })진단용 구간에는 __DBG 표식을 붙였다. 렌더 횟수와 루프 시간, Hermes 내부 통계를 함께 보냈다. 당시 비콘에 남은 세 줄은 다음과 같았다.
fps 51.1 dev 57 lcd 57 gcs 9916 heap 226MB
fps 50.7 dev 59 lcd 59 gcs 10115 heap 138MB
fps 53.1 dev 59 lcd 59 gcs 10311 heap 54MBDevice와 Lcd의 렌더 횟수는 3초에 57~59회였다. 초당 약 20회로 본체 전체와 액정이 같은 빈도로 다시 렌더링되고 있었다.
GC 누적 횟수로 읽은 값은 3초마다 약 200씩 늘었다. 힙 값은 226MB, 138MB, 54MB로 내려갔다. 이 세 값만으로 메모리가 계속 쌓인다거나 누수가 있다고 확정할 수는 없었다. 잦은 렌더링과 GC 부담을 원인 후보로 좁혔다.
반 픽셀 비교가 걷기 속도와 같았다
게임 루프는 50ms마다 LCD를 갱신했다. 이 주기는 의도한 것이었다. 몬스터를 그리는 오버레이 목록은 키 문자열이 바뀌었을 때만 setOverlays로 React 상태를 갱신하도록 되어 있었다.
// useGame.ts
const Q = LCD_SIZE * 2
function overlayKey(list: OverlaySprite[]): string {
let k = ''
for (const s of list) {
k += `${s.id};${Math.round(s.xf * Q)};${Math.round(s.yf * Q)};...|`
}
return k
}좌표를 LCD 반 픽셀 단위로 반올림해 작은 차이는 무시하려는 비교였다. 그런데 걷기 속도가 50ms마다 0.5px였다. 한 프레임의 이동이 키가 구분하는 단위와 같아 걷는 동안 매 프레임 키가 달라졌다.
상태는 useGame 안에 있고 그 훅을 Device에서 불렀다. 상태 갱신이 본체 Device와 그 아래 Lcd까지 이어졌다. 화면 일부의 위치가 달라지는 동안 전체 컴포넌트가 초당 약 20회 렌더링되는 구조였다.
replacePicture에서 매 프레임 picture.value를 읽는 부분도 있었다. Reanimated 문서에 따르면 JS에서 SharedValue를 읽을 때 UI 쪽 값을 가져올 때까지 기다릴 수 있다. 대부분 비용은 작지만 반복해서 읽거나 UI가 바쁘면 대기가 늘 수 있어 이 부분도 후보에 넣었다. dispose()가 기대한 시점에 그림 메모리를 해제하는지도 의심했지만 이날 확인하지는 못했다.
버튼 문제의 원인은 아직 확정하지 못했다
네이티브가 터치를 받는 일과 JS의 버튼 핸들러가 실행되는 일은 다르다. React Native 성능 문서에서도 JS 스레드가 바쁘면 터치 반응이 늦어질 수 있다고 설명한다. 화면이 보인다는 것만으로 JS가 입력을 제때 처리한다고 볼 수는 없었다.
조사 끝에는 두 방향을 후보로 남겼다. Lcd와 오버레이의 리렌더링 범위를 줄이거나 위치를 SharedValue로 옮기는 방법, replacePicture를 단순하게 만드는 방법이었다. 이날은 수정하지 않았다.
재시작한 앱의 화면 버튼을 직접 눌렀을 때 정상인지도 끝까지 확인되지 않았다. 외부 press('A') 호출의 반응과 비콘 값까지 확인했고, 임시 진단 코드는 작업 트리에 남았다. CPU 수치와 GC 횟수는 조사 방향을 좁혀줬지만 버튼 무반응이나 메모리 누수를 입증한 결과는 아니었다.