React Native에서 터치 반응이 1.8초 동안 먹통이 되는 현상을 실기 측정으로 추적한 이야기 (범인은
요약
React Native 앱에서 온보딩 과정 중 발생하는 1.8초의 터치 먹통 현상을 실측을 통해 분석했습니다. 근본 원인은 여러 개의 거대한 UTF-8 피드를 TextDecoder로 일괄 디코드할 때 JS 스레드가 장시간 블록되는 것이었습니다. 이를 해결하기 위해 데이터 스트리밍 및 yield 메커니즘 적용으로 성능을 크게 개선했습니다.
핵심 포인트
- JS 스레드 블록 현상은 실기 측정(Pixel 11)에서만 명확히 관찰됨.
- 원인은 여러 피드의 일괄 디코딩 과정에서 JS 스레드가 장시간 블록되는 것이었음.
- 해결책으로 데이터 청크 분할 및 `setTimeout`을 이용한 yield가 효과적임.
- JS 스레드 연속 블록 시간을 1793ms에서 227ms로 약 87% 감소시킴.
개요
RSS 리더 앱의 온보딩 화면에서 '다음' 버튼을 누르는 동작이 버벅거린다는 보고가 있었습니다.
처음에 의심했던 것은 '초기 피드 주입과 첫 동기화를 병렬로 실행하는 설계'였지만, 실기 측정으로 이를 부정했습니다.
진짜 원인은 거대한 UTF-8 피드를 TextDecoder.decode()로 일괄 디코드할 때, 이 환경에서는 JS 스레드가 1초 이상 블록되는 경우가 있었다는 것이었습니다. 병렬 가져오기 개수(10) 설정으로 인해 여러 개의 무거운 디코딩이 동시에 발생하며 블록이 쌓여 있었습니다.
대책은 다음 세 가지입니다.
- UTF-8 바이트 배열을 64KB 단위로 분할하여,
TextDecoder에stream: true로 전달한다 - - 청크 사이, 디코드 전, 파싱 전, 피드 처리 완료 후의 네 곳에서
setTimeout(resolve, 0)를 이용한 yield를 삽입한다 - - (실제로 검증하여 확인한)
Promise.resolve().then()와 같은 microtask의 yield는 효과가 없다
실기 측정(Android, Pixel 11, 깨끗한 신규 설치 상태) 결과, JS 스레드의 연속 블록 최대치는 **1793ms → 227ms (약 87% 감소)**까지 개선되었습니다.
증상
환경은 다음과 같습니다.
- Expo (Managed Workflow) + React Native + Hermes
- RSS 리더 앱. 온보딩의 최종 단계에서, 기본 피드의 첫 동기화를 백그라운드에서 실행하면서 대기 화면을 표시함
- 실기는 Pixel 11, iPhone 8
온보딩 대기 화면에서 '다음'을 눌러도 잠시 반응이 오지 않는다는 보고가 실기 확인 중에 올라왔습니다. 에뮬레이터나 시뮬레이터에서는 그다지 눈에 띄지 않았고, 실기에서만 명확하게 체감할 수 있는 증상이었습니다.
처음에 세웠던 가설과 그 부정
처음 의심했던 것은 온보딩 설계 자체였습니다.
이 앱의 온보딩은 대기 화면 표시와 동시에 '기본 피드 주입(seedDefaultFeeds())'과 '첫 동기화(SyncService.refresh())'를 백그라운드에서 병렬로 실행하는 구조입니다. 이것 자체가 무거운 것이 아닌가 하는 가설이었습니다.
하지만, 실기 측정(adb shell pm clear로 매번 깨끗한 신규 설치 상태 재현)으로 이 가설은 부정되었습니다. 병렬 실행 구조 자체를 바꿔도 체감되는 무거움은 변하지 않았습니다.
여기서 '왠지 모르게 무거울 것 같다'는 느낌에 대응하는 것을 멈추고, 실측을 하기로 했습니다.
실기 측정으로 JS 스레드 블록 시각화하기
피드별 decode/parse 시간을 기록하는 측정 코드와, setInterval의 드리프트(drift)를 이용해 JS 스레드의 블록 시간을 시각화하는 프로브를 임시로 설치하여 실기에서 여러 번 측정했습니다.
실제로 사용한 것은 utils/perfProbe.ts라는 임시 파일이었으며, 블록 감지 부분만 발췌하면 다음과 같습니다 (커밋하지 않았고, 원인 분리 후에 삭제했습니다).
// 임시 측정용 코드. 온보딩의 '다음' 버튼이 무거운 문제의 원인 분리가 끝나면
// 삭제할 것(커밋 안 함).
const blockingEvents: { atMs: number; driftMs: number }[] = [];
...
setInterval은 본래 intervalMs마다 발화하지만, JS 스레드가 동기 처리로 블록되어 있는 동안에는 콜백 자체가 실행될 수 없습니다. 다음에 실행되었을 때의 경과 시간과 기대값의 차이(drift)가, JS 스레드가 예정대로 처리를 진행할 수 없었던 시간의 근사치(다른 지연 요인도 섞일 수 있음)가 됩니다.
결과적으로, JS 스레드의 연속 블록 최대치는 1793ms였으며, 즉 최대 약 1.8초 동안 JS 스레드가 다음 처리를 진행할 수 없는 구간이 발생했고, 그동안 터치 조작이 전혀 작동하지 않는 상태임을 알게 되었습니다.
진짜 원인은 기본 피드 안에 포함된 거대한 UTF-8 피드였습니다.
| 피드 | 크기 |
|---|---|
| MELOS | 3.2MB |
이는 JS 스레드 상에서 동기적으로 실행됩니다. 측정 결과, 거대한 UTF-8 피드 일괄 디코딩이 블로킹의 주원인으로 떠올랐습니다. 다만, 이 시점에서는 피드별 상세 측정에 문제가 있어 정확한 값은 측정 코드를 수정한 후에 재확인하고 있습니다. 게다가 동시 실행 수를 10개로 병렬 가져오고 있기 때문에, 여러 무거운 디코딩이 같은 타이밍에 실행되어 블로킹이 쌓이고 있었습니다. XML 파싱(fast-xml-parser) 자체의 부하는 디코딩의 1/10 이하로 무시할 수 있는 수준이라 주원인은 아니었습니다.
측정 코드 자체의 버그에도 한 번 빠졌다
PerfProbe에는 블록 감지뿐만 아니라, 피드별 decode 시간・인코딩・사이즈를 기록하는 부분도 있었습니다. 초기 구현은 다음과 같았습니다.
// RssService.ts (초기 구현, 버그 있음)
export const __perfLast = {
decodeMs: 0,
...
호출 측(SyncService.ts의 병렬 worker)에서는 await가 반환된 후에 이 __perfLast를 읽어 기록했습니다.
const result = await RssService.fetchArticles(feed.url /* ... */);
// 이 타이밍까지 다른 worker의 decodeFeedBytes가 __perfLast를
// 덮어쓰고 있을 수 있음
...
__perfLast는 모듈 레벨의 공유 객체입니다. 10개로 병렬 worker를 돌리고 있기 때문에, 어떤 worker의 await fetchArticles(...)가 해결된 후 다음 줄이 실제로 실행되기 전 사이에, 다른 worker의 decodeFeedBytes가 먼저 실행되어 __perfLast를 덮어쓸 수 있습니다. JS는 싱글 스레드이지만, await할 때마다 다른 비동기 처리에 실행 순서가 돌아가기 때문에, 공유 가변 상태를 거치면 이런 종류의 '값 오인식'이 발생합니다.
이는 스스로 깨닫지 못했고, 측정 코드를 봐준 리뷰에서 지적받았습니다. 지적의 요점은, 'block最大・block合計'(setInterval의 드리프트와 독립적으로 측정하는 값)는 경쟁 영향에 받지 않아 신뢰도가 높지만, __perfLast를 거친 피드별 상세 내역(decode 시간・사이즈)은 신뢰할 수 없다'는 것이었습니다. 앞서 언급된 MELOS・WWD JAPAN・chiik이 무겁다는 특정 역시 이 상세 측정에 의존했던 부분이므로, 수정 후 재확인이 필요했습니다.
수정한 부분은 공유 객체에 쓰기하는 것을 멈추고, 타이밍 정보를 함수의 반환값으로 전달하는 형태로 바꾼 것입니다.
// 수정 후: 반환값으로 클로즈드하게 전달
function decodeFeedBytes(
arrayBuffer: ArrayBuffer,
...
fetchArticles의 반환값에도 타이밍 정보를 포함하도록 하고, 호출 측은 자신이 await한 결과에서만 값을 읽도록 했습니다. 이렇게 되자 병렬 worker 간의 값 오인식이 발생하지 않았고, 피드별 상세 내역도 안정적으로 재현되었습니다.
수정 후 재측정한 결과, melos.media/feed(3.2MB)는 일괄 decode만으로 1379ms, www.wwdjapan.com/feed(1.7MB)에서는 709ms, chiik.jp/feed(1.4MB)에서는 603ms라는 값이 안정적으로 재현되었습니다. 단일한 거대한 디코딩 자체가 1초 이상 JS 스레드를 연속 점유하고 있다는 추정이 여기서 뒷받침되었습니다.
병렬 처리를 디버깅할 때는 처리 대상의 코드뿐만 아니라, 측정 코드 자체도 병렬성의 함정에 빠지지 않았는지 의심해야 한다는 교훈입니다.
대책: 청크 decode + macrotask yield
대응은 services/RssService.ts에 집중되었습니다.
먼저, UTF-8 바이트열을 64KB 단위로 분할하고, TextDecoder에 stream: true를 전달하면서 순차적으로 디코딩합니다.
microtask의 yield에서는 효과가 없었다
이 부분이 가장 큰 배움이었습니다.
처음에 yieldToEventLoop()를 구현했을 때, Promise.resolve().then()와 같은 microtask만 이용한 yield를 시도했습니다. 하지만 이것으로는 체감 성능이나 블록 측정값 모두 전혀 개선되지 않았습니다.
await Promise.resolve()와 같은 microtask의 yield 역시 실행 중인 처리는 일단 중단됩니다. 다만, 그 다음 내용도 microtask로서 즉시 실행 대상이 됩니다. microtask 큐가 비워질 때까지는 타이머나 터치 이벤트 같은 다음 태스크를 처리할 기회가 이벤트 루프에 돌아오지 않습니다. 따라서, 처리를 세밀하게 분할했다고 생각했더라도, microtask만 연속적으로 사용하고 있는 한 UI 이벤트로 제어권을 반환한다는 목적에는 불충분했던 것입니다.
반면 setTimeout(resolve, 0)는 지속적인 처리를 다음 **태스크(macrotask)**로 스케줄링합니다. 이를 통해 그 지속적 처리가 실행되기 전까지 다른 대기 중인 처리(터치 이벤트 등)가 이벤트 루프에서 처리될 여지가 생깁니다. 실제로 이 방식으로 변경하자, 처음으로 블록 측정값과 체감 성능 모두 개선되었습니다. '비동기로 만드는 것'만으로는 충분하지 않고, '지속적 처리를 microtask와 macrotask 중 어느 큐에 돌려야 하는가'까지 의식해야 한다는 점은 구현하기 전까지는 사각지대였습니다.
TextDecoder가 청크 경계에서 깨지지 않는지 검증
Hermes의 청크 분할에서도 또 하나 신경 쓰였던 부분은 멀티바이트 문자가 청크 경계에서 분단되었을 때 발생하는 문자 깨짐 현상이었습니다. stream: true를 전달함으로써 디코더 내부가 미확정 바이트를 유지하고 다음 청크와 합쳐서 해결하는 사양인 것으로 알고 있지만, Hermes 환경에서의 구현을 전적으로 신뢰하기에는 위험합니다.
그래서 일괄 decode 결과와 청크 decode 결과를 모든 피드에서 비교하며 검증했습니다.
- Android 실기 (Pixel 11, 75건): 완벽하게 일치(mismatch = 0)
- iOS 시뮬레이터 (iPhone 17, 78건): 완벽하게 일치(mismatch = 0)
문제없다고 판단했습니다.
효과
Android 실기(pm clear 후의 진정한 신규 설치 상태, 동일 조건 비교)에서의 결과입니다.
| 지표 | Before | After |
|---|---|---|
| JS 스레드 연속 블록 최대 | 1793ms | 227ms (약 87% 감소) |
| ... | ||
| yield를 삽입하는 만큼 오버헤드가 증가해서 전체적으로는 느려질 것이라고 예상했지만, 실제로는 단축되었습니다. 장시간 JS 스레일 점유가 해소되면서 다른 피드의 응답 처리 등을 더 빠르게 진행할 수 있게 되었을 가능성이 있지만, wall time이 단축된 직접적인 원인까지 이번에 분리해내지는 못했습니다. |
iOS 시뮬레이터 (iPhone 17)에서도 비슷한 개선 추세는 보였지만, 블록 최대 546ms · 300ms 초과 2회로, Android 실기만큼 명확하게 개선되지는 않았습니다. 특정 피드의 디코딩이 Android 실기의 약 2배가 걸리고 있으며, setTimeout의 오버헤드인지 TextDecoder 때문인지는 명확하지 않습니다.
자체적인 속도 차이에 시뮬레이터/Hermes 환경 고유의 영향이 더해졌을 가능성이 있습니다. Simulator는 실제 기기의 성능 특성을 반영하지 않기 때문에, 이 수치만으로 평가를 확정하기보다는 실제 기기(iPhone 8, TestFlight 경유)에서의 체감 확인도 별도로 진행했습니다.
제외한 선택지
- 병렬도(10) 낮추기: MELOS와 같은 단일하고 무거운 피드의 디코드 시간 자체(앞서 언급된 1379ms)는 단축할 수 없으므로, 근본적인 해결책이 아니라고 판단하여 제외함 -
- Shift_JIS/EUC-JP도 마찬가지로 청크화하기: 실측에서 문제가 된 거대한 피드는 모두 UTF-8이었기 때문에, 대상을 넓혀 문자 경계 분할이나
encoding-japanese측의 상태 관리라는 새로운 리스크를 증가시킬 필요는 없다고 판단했습니다. 향후에 거대한 비(非)UTF-8 피드가 실측에서 문제가 된 시점에 대응하기로 함 - - 동기 시작을 대기 화면 표시 후까지 지연시키기: 대기 시간 자체가 늘어나는 트레이드오프가 있어, 이번에는 채택하지 않음
교훈
- '왠지 무거워 보이는 설계'를 의심하기보다 먼저 실제 기기에서 수치를 측정한다. 이번에도 첫 번째 가설(병렬 실행 설계)은 실측으로 쉽게 부정되었다.
- 병렬 처리의 버그를 의심할 때는, 측정 코드 자체가 병렬성의 함정(공유 변수의 경쟁)에 빠져있지 않은지도 의심해본다.
- '비동기로 하면 블록이 해소된다'는 것은 정확하지 않다. microtask와 macrotask 중 어느 것으로 처리를 되돌리느냐에 따라, 실제로 다른 처리에 순서가 돌아갈지가 달라진다 -
환경 고유의 최적화(이번에는 UTF-8 한정 청크화)를 적용할 때는, 대상을 넓히지 않는 것까지 포함하여 범위를 명확하게 정해두는 것이 좋다.
마찬가지로 'React Native에서 터치가 순간적으로 먹통이 된다', '무거운 동기 처리로 UI가 멈춘다'와 같은 증상에 직면한다면, 먼저 setInterval의 드리프트 측정 등으로 JS 스레드의 블록을 시각화하고, 그 위에 await의 위치와 yield의 종류(micro/macrotask)를 의심해보는 것을 추천합니다.
참고로, 이 글에서 소개된 트러블은 직접 만든 RSS 리더 'Filto' 개발 중에 실제로 겪었던 것입니다. Filto는 React Native + Expo로 개발되었으며, 소스 코드도 GitHub에 공개하고 있습니다.
소스 코드: GitHub
앱: App Store / Google Play
실제로 어떤 앱을 만들고 있는지 궁금하다면 꼭 한번 살펴보시기 바랍니다.
Discussion
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기