← Blog

모든 화면 위의 퀵바가 앱 전체를 느리게 했다 — React Native 전역 오버레이 성능 트러블슈팅

JunGyu Kim · · 11분 읽기React NativeExpo성능 최적화트러블슈팅
목차

1. 문제 상황: 시뮬레이터에서는 빨랐습니다

데일리베이비의 정체성은 화면 하단에 항상 떠 있는 플로팅 퀵액션 바입니다. 홈이든 캘린더든 통계든, 어느 탭에 있어도 수유·기저귀·수면 버튼이 손닿는 곳에 있고, 탭 한 번이면 기록이 저장됩니다. 설계 편에서 다뤘던 이 "1초 기록"의 상징을 구현하는 방법은 단순합니다. expo-router 탭 레이아웃의 커스텀 탭바 위에 퀵바를 전역 오버레이로 마운트하는 것입니다. 화면들이 바뀌는 동안에도 퀵바는 한 번만 마운트된 채 그 위에 계속 떠 있습니다. UX 관점에서는 정답이었고, 지금도 정답이라고 생각합니다. 문제는 이 구조가 성능 관점에서는 함정을 품고 있었다는 것입니다.

증상은 서서히 나타났습니다. 개발 중에는 시뮬레이터와 최신 기기에서만 확인하니 몰랐는데, 몇 세대 전 iOS 실기기에서 앱을 오래 쓰다 보니 탭 전환이 미묘하게 굼뜨고, 기록을 등록한 직후 리스트 스크롤이 한 박자 걸리는 프레임 드랍이 느껴졌습니다. 결정적인 신호는 사용자 제보였습니다. "버튼을 눌렀는데 반응이 없어서 다시 눌렀더니 기록이 두 개 생겼다"는 내용이었습니다. 처음에는 중복 방지 로직이 빠진 UX 버그라고 생각했지만, 곱씹을수록 본질은 다른 곳에 있었습니다. 버튼이 늦게 반응한 이유가 있을 것이고, 그 이유는 아마 JS 스레드가 그 순간 다른 일로 바빴기 때문일 것입니다. 중복 기록은 버그가 아니라 성능 문제의 증상이었습니다.

재현 조건을 좁혀 보니 공통분모가 나왔습니다. 기록이 수백 건 쌓인 오래된 계정일수록, 그리고 시뮬레이터가 아닌 실기기일수록 증상이 뚜렷했습니다. 갓 설치한 테스트 계정으로는 아무리 눌러도 쾌적합니다. 개발자가 매일 보는 환경과 사용자가 매일 겪는 환경이 다르면, 성능 문제는 가장 늦게 발견되는 버그가 됩니다. 게다가 이런 문제는 크래시처럼 스택 트레이스를 남기지도 않습니다. "가끔 좀 느린 것 같아요"라는 감각적인 제보만 남기 때문에, 어디서부터 파야 할지 스스로 가설을 세워야 했습니다. React Native에서 터치 반응 지연과 스크롤 프레임 드랍이 함께 온다면 첫 번째 용의자는 JS 스레드 포화입니다. 그리고 이 앱에서 JS 스레드를 상시 점유할 수 있는 후보는, 모든 화면에 함께 떠 있는 저 퀵바뿐이었습니다.

2. 원인 추적: 전역 오버레이는 구독의 블랙홀이 된다

React DevTools 프로파일러로 기록 등록 순간을 찍어 보고 나서야 그림이 보였습니다. 수유 버튼 한 번에 리렌더 하이라이트가 퀵바에서만 반짝여야 할 텐데, 실제로는 퀵바, 글로벌 헤더, 홈 리스트, 심지어 앱 루트 레벨까지 넓게 번쩍였습니다. 기록 하나를 추가했을 뿐인데 앱의 절반이 다시 그려지고 있었던 것입니다. 원인을 하나씩 걷어내며 목록을 만들었습니다.

첫째, 스토어 전체 구독입니다. 퀵바는 Zustand 스토어에서 const { addRecord, updateRecord, records, ... } = useRecordStore()처럼 구조분해로 값을 꺼내고 있었습니다. 셀렉터 없는 구조분해는 곧 스토어 전체 구독입니다. 그런데 records는 이 앱에서 가장 뜨거운 상태입니다. 내가 기록을 추가할 때, 가족의 기기에서 온 변경이 반영될 때, 증분 동기화가 병합될 때, 하다못해 isSyncing 플래그가 토글될 때마다 스토어가 갱신되고, 그때마다 퀵바가 통째로 리렌더됩니다.

둘째, 전파 범위입니다. 일반 화면 컴포넌트라면 리렌더가 그 화면 안에서 끝나지만, 퀵바는 모든 화면 위에 떠 있는 전역 컴포넌트입니다. 전역 컴포넌트의 불필요한 리렌더는 앱 어디에 있든 따라다니는 배경 소음이 됩니다. 더 나쁜 것은 같은 패턴이 루트에도 있었다는 점입니다. 수유 텀 알림을 관리하는 훅이 앱 루트 레벨에서 records 전체를 구독하고 있어서, 기록이 바뀔 때마다 루트부터 리렌더가 출발했습니다.

셋째, 파생 상태가 배열 참조에 묶여 있었습니다. 퀵바는 "지금 진행 중인 수유나 수면이 있는가"를 판별해 버튼 라벨과 펄스 애니메이션을 바꿉니다. 이 ongoingRecords 계산이 records 배열을 의존성으로 둔 useMemo였는데, 배열 참조는 내용과 무관한 변경에도 매번 바뀌므로 메모이제이션이 사실상 무력화됩니다. 게다가 판별 로직 자체도 타입별로 배열을 다시 훑는 구조라, 기록이 쌓일수록 셀렉터 비용이 정직하게 늘었습니다.

넷째, 리스트 쪽의 합산입니다. 홈 리스트의 RecordItem은 React.memo로 감싸져 있었지만, 커스텀 비교 함수 안에서 details 객체를 JSON.stringify로 직렬화해 비교하고 있었습니다. 리렌더를 막으려고 둔 비교 함수가 아이템 수만큼의 직렬화 연산을 매 렌더 사이클마다 수행하는 역설입니다. 여기에 진행 중 기록의 펄스 애니메이션이 화면 밖으로 스크롤된 아이템에서도 무한 루프로 돌고 있었습니다.

다섯째, 저장소입니다. 스토어는 persist 미들웨어로 AsyncStorage에 캐시되는데, 기본 동작은 상태가 바뀔 때마다 전체를 직렬화해 쓰는 것입니다. records가 자주 바뀌는 만큼 JSON.stringify와 스토리지 쓰기가 JS 스레드에서 반복되고 있었습니다. 어느 하나가 결정타는 아니었습니다. 프레임 드랍은 이 다섯 가지의 합이었고, 그래서 더 잡기 어려웠습니다.

3. 해결 1: 구독을 잘게, 파생 상태는 원시값으로

가장 효과가 컸던 수술은 구독 구조였습니다. 우선 액션 함수들은 개별 셀렉터로 분리했습니다. useRecordStore((s) => s.addRecord)처럼 함수 참조만 구독하면 records가 아무리 바뀌어도 리렌더가 발생하지 않습니다. 구조분해 한 줄을 셀렉터 네 줄로 바꾸는 것이라 코드는 오히려 길어지지만, "이 컴포넌트가 스토어의 무엇에 반응하는가"가 코드에 명시된다는 부수 효과가 있습니다. 나중에 리렌더 문제를 다시 추적할 때 이 명시성이 문서 역할을 해 주었습니다.

진짜 고민은 ongoingRecords였습니다. 진행 중 기록 판별은 records 내용에 의존하므로 구독을 아예 끊을 수는 없습니다. 대신 셀렉터가 객체나 배열이 아니라 원시값을 반환하게 만들었습니다. 셀렉터 안에서 매번 새 객체를 만들어 반환하면 내용이 같아도 참조가 달라져 매번 리렌더되지만, 문자열은 값이 같으면 비교도 같습니다. 그래서 진행 중인 기록의 타입과 id를 이어 붙인 문자열 키를 만들어 반환하는 방식을 택했습니다.

// 파생 상태를 문자열(원시값)로 만들어 Object.is 비교를 통과시킨다
const ongoingKey = useRecordStore(
  useCallback((s) => {
    const parts: string[] = [];
    for (const r of s.records) {            // 단일 순회 + 조기 종료
      if (isOngoingCandidate(r)) {
        if (r.details?.start_time && !r.details?.end_time) {
          parts.push(`${r.type}:${r.id}`);  // 예: "sleep:abc123"
        }
      }
      if (allTypesResolved()) break;
    }
    return parts.join(",");
  }, [])
);
 
// 문자열 키가 실제로 바뀔 때만 Record 객체를 다시 조회
const ongoingRecords = useMemo(() => buildOngoingMap(), [ongoingKey]);

Zustand는 셀렉터 반환값을 Object.is로 비교하므로, records 배열의 참조가 백 번 바뀌어도 진행 중 기록의 구성이 같다면 문자열도 같고, 컴포넌트는 리렌더되지 않습니다. 기록 추가의 대부분은 진행 중 상태를 바꾸지 않는 기저귀나 완료형 기록이라, 이 한 번의 변경으로 퀵바의 리렌더 횟수가 체감될 만큼 줄었습니다. 수유 경고 표시에 쓰이는 마지막 수유 시각도 같은 원리로 recorded_at 문자열 하나만 구독하게 바꿨습니다.

다만 공짜는 아닙니다. 셀렉터는 스토어가 갱신될 때마다 실행되므로 셀렉터 자체가 가벼워야 합니다. 처음 버전은 타입별로 배열을 다섯 번 훑는 O(n×5) 구조였는데, 실기기 프로파일링에서 이 비용이 눈에 띄어 단일 순회에 모든 타입을 찾으면 조기 종료하는 O(n)으로 다시 손봤습니다. 리렌더를 줄이려 만든 장치가 새로운 상시 비용이 되지 않는지까지 확인해야 최적화가 끝납니다. 같은 패턴으로 루트의 알림 훅도 records 전체 대신 마지막 수유·기저귀·수면 기록의 id만 모듈 레벨 상수 셀렉터로 구독하게 바꿔, 앱 루트에서 출발하던 리렌더를 차단했습니다. 커스텀 탭바 컴포넌트도 렌더 함수 안에 정의돼 매번 새로 만들어지던 것을 바깥으로 추출하고 React.memo로 감쌌습니다.

4. 해결 2: 애니메이션, 리스트, 저장소의 마찰 줄이기

구독 정리가 끝난 뒤에도 남은 프레임 드랍을 하나씩 걷어냈습니다.

애니메이션부터입니다. 진행 중 기록을 알리는 펄스는 opacity와 scale만 쓰는 애니메이션이라 전부 네이티브 드라이버로 보내 JS 스레드와 무관하게 돌게 했습니다. 리스트 아이템의 펄스는 화면에 보일 때만 돌도록 게이트를 달았습니다. FlatList의 onViewableItemsChanged로 노출 중인 아이템 id 집합을 추적해서, 화면 밖 아이템은 애니메이션을 정지시키는 방식입니다. 새 아키텍처(Fabric)에서는 사소한 함정도 있었습니다. 애니메이션 루프를 멈추고 값을 초기화하는 순서가 비동기 teardown과 엇갈리면 간헐적으로 어긋난 상태가 남아서, 값 리셋을 requestAnimationFrame으로 한 프레임 늦춰 정리했습니다.

퀵바의 접기·펼치기 전환도 다듬었습니다. 접힌 미니 바와 펼친 바를 조건부 마운트로 갈아끼우면 전환 시마다 마운트 비용이 들고 애니메이션이 뚝뚝 끊깁니다. 두 상태를 모두 마운트해 두고 opacity와 pointerEvents만 전환하니 애니메이션이 매끄러워졌고, 전환 중 중복 탭으로 상태가 꼬이는 것은 진행 중 플래그 하나로 막았습니다. 커뮤니티 탭처럼 퀵바가 필요 없는 화면에서도 언마운트 대신 같은 방식으로 숨겨서, 탭을 오갈 때마다 퀵바가 다시 마운트되는 비용을 없앴습니다.

리스트의 memo 비교 함수는 JSON.stringify를 버리고 얕은 비교로 바꿨습니다. details 객체의 참조가 같으면 즉시 통과하고, 다를 때만 키를 순회하며 값을 비교합니다. 직렬화 한 번이 아깝지 않아 보여도, 아이템 수와 렌더 빈도를 곱하면 리스트 스크롤의 프레임 예산을 갉아먹는 비용이 됩니다. 비교 함수는 렌더보다 자주 불리는 코드라는 사실을 그때 실감했습니다.

저장소는 쓰기 스로틀로 풀었습니다. persist의 스토리지 어댑터를 감싸서 쓰기를 2초 단위로 모아 한 번에 반영하고, 대신 앱이 백그라운드나 비활성으로 전환되는 순간에는 보류 중인 쓰기를 즉시 flush합니다. 기록 연타나 동기화 병합처럼 상태가 짧은 시간에 몰아치는 구간에서 직렬화 횟수가 크게 줄었고, 강제 종료로 인한 데이터 유실은 flush 타이밍으로 방어했습니다. 같은 맥락에서 기록 수정 모달도 항상 마운트해 두는 대신 열릴 때 마운트하고 닫기 애니메이션이 끝난 뒤 언마운트하는 lazy-mount로 바꿨습니다.

마지막으로 그 중복 기록 제보입니다. 타입별로 진행 중인 요청을 Set으로 추적해서, 같은 버튼의 연타는 첫 요청이 끝날 때까지 무시하게 했습니다. 다섯 줄짜리 가드지만, 위의 최적화들로 버튼 반응이 빨라진 뒤에는 애초에 연타할 이유 자체가 줄었습니다. 사용자는 느려서 두 번 눌렀던 것이지, 두 번 누르고 싶었던 것이 아니었습니다.

5. 배운 점

이번 트러블슈팅에서 얻은 가장 큰 교훈은 전역 오버레이의 이중성입니다. 어느 화면에서나 보인다는 것은 UX의 축복이지만, 렌더링 관점에서는 "모든 화면의 성능에 상시 개입하는 컴포넌트"라는 뜻입니다. 전역에 상주하는 컴포넌트일수록 구독 설계는 가장 보수적이어야 합니다. 화면 하나의 낭비는 그 화면의 문제로 끝나지만, 오버레이의 낭비는 앱 전체의 바닥 소음이 됩니다.

기술적으로는 "무엇을 그리느냐"보다 "무엇을 구독하느냐"가 성능을 결정한다는 것을 다시 확인했습니다. 렌더 트리를 아무리 메모이제이션해도 구독이 넓으면 소용이 없고, 반대로 구독을 원시값 수준으로 좁히면 memo 없이도 조용해집니다. 파생 상태를 문자열 키로 원시화하는 기법은 그중에서도 투자 대비 효과가 가장 컸습니다. 그리고 최적화 장치 자체의 비용을 의심하는 습관도 남았습니다. JSON.stringify 비교 함수처럼 리렌더를 막으려던 코드가 연산 병목이 되기도 하고, 셀렉터처럼 리렌더를 줄이려던 코드가 O(n×5) 상시 비용이 되기도 합니다.

프로세스 면에서는 두 가지입니다. 성능 문제는 실기기에서, 그것도 데이터가 쌓인 오래된 계정으로 확인해야 합니다. 시뮬레이터의 쾌적함은 아무것도 보증하지 않습니다. 그리고 "기록이 두 개 생겨요" 같은 기능 버그 제보가 실은 성능 문제의 그림자일 수 있다는 것. 증상을 고치기 전에 왜 그 증상이 나올 수 있었는지 한 겹 아래를 보는 습관이 이번에 저를 구했습니다.

덧붙이자면, 이 최적화 작업에는 후일담이 있습니다. 당시 네트워크 방어를 위해 넣었던 전역 fetch 10초 타임아웃이 몇 주 뒤 Supabase Realtime 연결을 조용히 끊어먹는 부작용을 일으켰습니다. 최적화의 나비효과에 대한 그 이야기는 지난 글에 정리해 두었습니다. 성능을 얻는 수정이 무엇을 잃게 하는지는, 대개 한참 뒤에야 청구서로 날아옵니다.

빠른 기록에 진심인 육아앱이 궁금하다면, 데일리베이비를 한번 써보시기 바랍니다. 새벽 세 시에 엄지 하나로 눌러보면 이 글의 최적화가 어디에 쓰였는지 느끼실 수 있을 것입니다.

데일리베이비 - App Store에서 다운로드

데일리베이비 - Google Play에서 다운로드

광고

관련 글