EP 16

빠른 게 아니라, 빠르게 느껴지게 (2)


2026년 8월·6 min read·#performance#instrumentation#debugging

EP 08에서 이렇게 끝냈다.

"빠른 것보다 빠르게 느껴지는 게 때로는 더 중요하다."

그건 페퍼의 응답 속도 얘기였다. 이번엔 앱 자체가 느렸다. 그리고 이번엔, 느끼는 걸 넘어 숫자로 재야 고칠 수 있었다.


카톡은 즉시 뜬다

Danny(=나)가 신고했다. 앱 열 때, 탭 바꿀 때마다 스피너가 돈다. 카톡은 열면 바로 예전 대화가 떠 있는데.

원인은 캐시가 전부 메모리 한정이라는 거였다. prefetch도, 세션도, 방 메시지도 다 메모리에만 있어서, 앱을 완전히 껐다 켜면(콜드스타트) 전부 날아간다. 매번 빈 화면 → 스피너 → 로드.

해결은 세 겹이었다.

  1. 디스크 영구 캐시 — 앱을 꺼도 남는 캐시. 두 번째 실행부터는 마지막 상태를 즉시 그린 다음 뒤에서 갱신한다.
  2. prefetch — 앱 시작 시 5개 탭 데이터를 병렬로 미리 당긴다.
  3. SWR(stale-while-revalidate) — 탭에 들어가면 있는 데이터를 바로 보여주고, 뒤에서 조용히 최신화. 스피너 없이.

기술 결정 하나. 디스크 캐시를 뭘로? MMKV가 빠르지만 새 네이티브 의존성이 붙는다. 4인용 앱에 그건 과하다고 판단하고 expo-file-system 기반으로 직접 얇은 KV(kvStore.ts)를 만들었다. 새 네이티브 의존성 0. 나중에 필요하면 이 파일 하나만 갈아끼우면 된다.


근데 채팅방은 여전히 느렸다 — 그리고 왜인지 몰랐다

캐시로 대부분 잡혔는데, 채팅방 진입과 스크롤이 유독 느렸다. 코드를 아무리 봐도 어디가 문제인지 안 보였다.

비개발자의 본능은 여기서 "코드를 노려보며 짐작"이다. 근데 EP 11에서 배운 게 있었다. 짐작하지 말고, 실제로 뭐가 일어나는지 봐라.

그래서 계측을 심었다. 컴포넌트가 mount/unmount될 때마다 인스턴스 id를 찍고, 렌더될 때마다 카운트를 올렸다. 그리고 앱을 실제로 만졌다.

숫자가 두 가지를 드러냈다.

원인 1 — 탭 전환마다 채팅 전체가 다시 태어남.

Family 탭 → id: h08axj
Direct 탭 → id: my9w86   ← 인스턴스 id가 바뀜 = 새로 mount됨

chat.tsxtab === 'family' ? <ChatView/> : <FlatList/> 삼항 조건부 렌더였다. "탭은 인라인이라 화면 전환처럼 죽지 않는다"고 믿었는데 — 틀렸다. React는 같은 위치에서 엘리먼트 타입이 바뀌면 통째로 unmount/mount한다. 세그먼트를 누를 때마다 채팅방이 매번 새로 태어나고 있었다.

해결: 삼항을 버리고, Family/Direct 둘 다 항상 마운트된 <View>로 감싼 뒤 비활성 쪽만 display:'none'으로 숨긴다.

원인 2 — 스크롤 한 번에 메시지 46개가 전부 다시 그려짐.

render-count 로그에서 스크롤 1회에 46개 카운트가 일제히 튀는 걸 봤다. renderItem이 매번 reactions.filter(...)새 배열을 만들어 MessageItem에 넘기고 있었다. MessageItemmemo인데, [] !== []라 리액션 없는 메시지까지 전부 memo가 무력화됐다.

해결: reactionsuseMemoMap<message_id, Reaction[]>으로 사전 그룹화하고, 매칭 없으면 모듈 레벨 EMPTY_REACTIONS 공유 참조를 반환. 같은 빈 배열을 계속 넘기니 memo가 살아난다.


두 버그 다, 코드만 봐선 안 보였다. 계측 로그의 숫자가 보여줬다. 인스턴스 id h08axj → my9w86, 스크롤당 46.

EP 11에서 "payload를 봐라, raw response를 봐라"였던 게, 여기서는 "mount 로그를 봐라, render count를 봐라"가 됐다. 같은 원칙이다. 비개발자가 성능을 고치는 법은 코드를 더 잘 읽는 게 아니라, 느낌을 숫자로 바꾸는 것이었다.

"느리다"는 고칠 수 없다. "탭 전환마다 46번 리렌더"는 고칠 수 있다.