Skip to content
Park Hyoin PARKHYO.IN
Go back

탭을 누르면 화면이 잠깐 얼어붙었다 — 원인 추적기

Edit page

1인 개발로 만드는 서비스에서 “간헐적으로 탭 전환이 끊기는” 문제를 추적한 기록. 결론부터: 단일 버그가 아니라 구조적 비용이었고, 삽질도 많았다. build-in-public 정신으로 실패 과정까지 남긴다.

Table of contents

Open Table of contents

1. 증상 — 무엇이 문제였나

모바일에서 앱을 쓰다 보면, 하단 탭(홈·목표·분석)을 누를 때 가끔 이상했다.

  • 정상: 탭을 누르면 “이동 중” 로딩이 잠깐 뜨고 새 화면으로 부드럽게.
  • 문제: 로딩 표시도 없이 이전 화면이 그대로 몇 초 얼어붙어 있다가, 갑자기 새 화면으로 점프.

“간헐적”이라 재현이 어렵고, 특히 앱을 잠깐 백그라운드로 보냈다가 돌아온 직후 자주 나는 느낌이었다. 런칭을 앞두고 “우리 서비스가 얼마나 빠른지 객관적 지표조차 없다”는 불안까지 겹쳤다.

2. 첫 번째 삽질 — “성능을 잰다”는 착각

성능을 재겠다고 제일 먼저 PageSpeed Insights / Lighthouse 를 돌렸다. 그런데 여기서 개념을 헷갈렸다.

Lighthouse 는 “페이지가 처음 뜨는 속도(로딩)” 를 잰다. 내 문제는 “이미 떠 있는 앱에서 탭을 누를 때의 끊김(인터랙션)” 이다. 완전히 다른 층이다.

Lighthouse 가 LCP(Largest Contentful Paint) 같은 숫자를 뱉으니 거기에 꽂혀서, 정작 관계없는 랜딩 페이지 이미지 최적화 까지 하러 삼천포로 빠졌다. (이미지 최적화 자체는 나쁘지 않았지만, 내 진짜 문제와는 무관했다.)

교훈 ①: 로딩 지표(LCP·FCP)와 인터랙션 반응성(INP·런타임 jank)은 다른 문제다. 도구부터 맞게 골라야 한다. 탭 전환 끊김을 보려면 Lighthouse 가 아니라 DevTools Performance 패널(트레이스 녹화) 이다.

여담으로, 실기기 원격 디버깅(adb)을 붙이려다 offline 지옥에 빠져 시간을 많이 썼다. 결국 데스크톱 크롬에서 CPU · 네트워크 스로틀을 걸고 프로파일하는 걸로 충분했다. 실기기가 꼭 필요한 건 아니었다.

3. 제대로 재기 — Performance 프로파일

데스크톱 크롬에서:

  • 로그인한 채로 앱을 열고
  • DevTools → Performance → CPU 4–6배 슬로다운 + 네트워크 Slow 4G (약한 모바일 흉내)
  • 녹화 시작 → 탭을 여러 번 왔다갔다 → 정지

타임라인의 Main 트랙에서 긴 태스크(Long Task, 빨간 삼각형 ▲) 를 찾는다. 탭을 누른 순간마다 긴 태스크가 하나씩 찍혔다.

4. 틀린 가설 — “Realtime 이 범인일 거야”

처음 세운 가설: 이 앱은 Supabase Realtime(WebSocket) 으로 기록을 실시간 동기화한다. 모바일이 백그라운드에 갔다 오면 WebSocket 이 재연결되는데, 그 재연결이 메인 스레드를 잡아서 탭을 누른 순간 얼어붙는 것 아닐까?

그럴듯했다. 그런데 프로파일을 열어 긴 태스크를 파보니:

  • JS 함수 호출 하나하나가 다 작았다 (1.6ms, 0.3ms…). Realtime 관련 코드가 시간을 먹는 흔적이 없었다.
  • 대신 Layout, Recalculate Style, Paint, Commit — 즉 브라우저 렌더링 파이프라인 이 시간을 다 먹고 있었다. 한 번 전환에 Layout 만 90–100ms 대(스로틀 기준).

가설이 틀렸다. 코드를 읽으며 세운 “Realtime 재연결” 이론을 프로파일이 정면으로 반증했다.

교훈 ②: 추측으로 고치지 말고 프로파일로 확인 하라. 코드만 읽으면 그럴듯한 오답을 확신하게 된다.

5. 진짜 원인 — 렌더/레이아웃 바운드

정리된 원인:

탭을 누를 때마다 이전 페이지의 DOM 을 통째로 헐고 새 페이지를 다시 레이아웃·페인트한다. 그 자체가 무거웠다.

이 앱은 각 탭이 서버에서 데이터를 가져오는 별도의 동적 페이지 다. 탭을 누르면:

  1. 서버 왕복(데이터 fetch)
  2. React 가 새 페이지의 컴포넌트 트리를 렌더(deep 한 재귀 렌더)
  3. 브라우저가 새 DOM 을 Layout → Paint

데스크톱 4–6배 스로틀에서 이 한 덩어리가 약 1.5초였다. 실제 모바일 CPU 가 딱 그 정도로 약하다. 그래서 폰에서 “얼어붙는” 것이었다.

프로파일에서 하나 더 발견: 공용 UI(상단 헤더·하단 탭바)가 각 페이지 안에 들어 있어서, 탭을 바꿀 때마다 헤더·탭바까지 다시 마운트되고, 헤더에 붙은 “계정 삭제 예약” 배너가 매 전환마다 서버에 재요청 을 날리고 있었다. 공용이어야 할 것이 페이지마다 다시 태어나고 있었다.

6. 시도한 해결 — 헤더·탭바를 공유 layout 으로

가장 눈에 띄는 낭비부터 잡기로 했다. Next.js App Router 의 route group + 공유 layout.tsx 로 구조를 바꿨다.

  • 인증된 앱 라우트(/, /analysis, /goals, /history, /settings)를 (app) route group 으로 묶고 (URL 은 그대로),
  • 공유 layout.tsx 에서 헤더·탭바를 한 번만 렌더,
  • 각 페이지는 “헤더와 탭바 사이의 콘텐츠” 만 반환.

기대: 탭을 바꿔도 헤더·탭바는 유지 되고(재마운트 X, 재요청 X), 페이지 콘텐츠만 갈아끼워진다 → 전환당 하는 일이 줄어든다.

왜 부분적으로만 성공했나

리팩터 후 다시 프로파일했더니 — Layout 이 여전히 지배적이었다.

  • ✅ 얻은 것: 헤더·탭바 재마운트 제거 + 배너의 반복 서버 요청 제거 + 깜빡임 제거 + 올바른 구조.
  • ❌ 못 얻은 것: 가장 큰 비용이 헤더·탭바(chrome)가 아니라 “페이지 본문을 다시 레이아웃하는 것” 이었다. 그건 그대로 남았다.

낭비 하나는 없앴지만, 지배적 비용은 건드리지 못했다. 솔직히 기대보다 이득이 작았다.

교훈 ③: “눈에 띄는 낭비” 가 항상 “가장 큰 비용” 은 아니다. 프로파일이 가리키는 지배적 항목 부터 봤어야 했다.

7. 결론 — 단일 버그가 아니라 구조적 비용

핵심은 이거다:

이 끊김은 고칠 수 있는 “버그” 하나가 아니라, 전체 페이지를 다시 그리는 SPA 네비게이션의 “구조적 비용” 이다.

  • 데이터가 적은 지금도 나는 걸 보면 데이터 양 문제도 아니다.
  • Realtime 도, 특정 함수도 아니다.
  • 약한 모바일 CPU 에서 full-page 전환의 Layout/Paint 비용 이 본질이다.

이런 종류는 한 방에 사라지지 않는다. 줄이려면 여러 개의 분산된 튜닝 이 필요하고, 각각의 ROI 는 크지 않다. 그래서 런칭을 막을 blocker 는 아니라고 판단 하고, 부분 개선(공유 layout)은 유지한 채 본격 튜닝은 런칭 후로 미뤘다.

8. 런칭 후 계획 — 서비스 중인데 UX 를 어떻게 안 해치고 고치나

런칭하면 실사용자가 있는 채로 고쳐야 한다. 다행히 두 가지가 유리하다.

(1) 배포가 무중단이다. Vercel + push-to-deploy 구조라 배포는 원자적(atomic)이다. 새 버전이 준비되면 트래픽이 순간 전환되고, 빌드 실패 시 이전 버전이 그대로 유지된다. 다운타임 없이 고칠 수 있다.

(2) 튜닝은 겉모습을 안 바꾼다. 아래 개선들은 화면은 그대로, 속도만 좋아지는 것이라 UX 를 해치지 않는다.

안전한 개선 순서 (측정 → 타깃 → 개선 → 검증 → 배포, 각 단계가 독립 배포 가능)

  1. 먼저 측정 지표를 붙인다. 실사용자의 INP(Interaction to Next Paint) 를 집계(Vercel Speed Insights, 쿠키리스). “탭 반응이 실제로 얼마나 느린가” 를 숫자로 잡아 개선을 증명한다.
  2. 비싼 CSS 감사. text-wrap: balance(text-balance), break-keep 같은 속성은 레이아웃을 여러 번 다시 계산한다. 남발한 곳을 찾아 꼭 필요한 데만 남긴다. → Layout 비용 직접 감소.
  3. 페이지당 DOM · 렌더 줄이기. 특히 히스토리가 쌓이면 무거워지는 화면(분석 탭은 전체 기록을 불러온다)을 최근 N개 + “더 보기” 로 바꿔 초기 렌더를 가볍게.
  4. JS 번들 다이어트. 초기 청크(수백 KB)를 코드 스플리팅·불필요 폴리필 제거로 줄여 하이드레이션·실행 부담을 낮춘다.
  5. (선택) 전환 자체를 가볍게. 자주 오가는 탭의 데이터를 캐시/프리페치해 서버 왕복을 줄인다.

각 단계는 브랜치 → 프리뷰 URL 에서 검증 → 머지 로 나가므로, 실서비스에 영향 없이 하나씩 안전하게 적용한다. 개선이 INP 숫자로 확인되면 유지, 아니면 롤백.

9. 얼마나 걸리나 (러프 · 1인 · 집중일)

작업예상
Speed Insights 로 INP 측정 붙이기0.5일
비싼 CSS 감사·정리1–2일
무거운 화면 페이지네이션(분석 등)2–3일
JS 번들 코드 스플리팅3–5일
전환 캐시/프리페치(선택)2–3일
  • “측정 + 비싼 CSS 정리” 만 먼저 하면 2–3일 안에 체감 개선을 노려볼 수 있다.
  • 전체는 약 1.5–2주 분량이지만, 각 항목이 독립적이라 점진적으로 나눠 배포하면 된다. 한 번에 다 할 필요 없다.

10. 교훈 정리

  1. 로딩 성능(Lighthouse)과 인터랙션 반응성은 다른 문제다. 도구부터 맞게.
  2. 추측으로 고치지 말고 프로파일로 확인하라. 그럴듯한 오답(나의 “Realtime” 가설)을 프로파일이 반증했다.
  3. “눈에 띄는 낭비” ≠ “가장 큰 비용”. 프로파일의 지배적 항목부터.
  4. 모든 게 단일 버그는 아니다. 어떤 느림은 구조적이고, 그럴 땐 “blocker 인가” 를 먼저 판단해 우선순위를 정한다.
  5. 무중단 배포 + 겉모습 불변 튜닝 이면, 서비스 중에도 UX 를 해치지 않고 점진 개선할 수 있다.

더 공부해볼 것

이 글이 얕게 지나간 것들:

  • INP (Interaction to Next Paint) — Core Web Vitals 의 인터랙션 지표. FID 의 후계. 어떤 상호작용이 왜 느린지 breakdown 하는 방법. web.dev — INP
  • Chrome DevTools Performance 패널 심화 — Long Task 뿐 아니라 Layout Shift Regions · Interactions 트랙 · Bottom-Up · Call Tree 활용. Analyze runtime performance
  • CSS contain 속성contain: layout 로 자식 레이아웃 변경이 부모로 전파되지 않도록 격리. 이 글의 “페이지 본문 레이아웃 비용” 을 부분 완화할 수 있는 카드. MDN — contain
  • content-visibility: auto — 뷰포트 밖 콘텐츠를 skip layout/paint. 히스토리 리스트처럼 긴 리스트에 특히 효과. web.dev — content-visibility
  • Next.js App Router — parallel routes · intercepting routes — 이 글에서 route group 만 썼지만, 더 정교한 라우팅 패턴으로 전환 비용을 줄일 여지가 있음. Next.js Routing
  • text-wrap: balance 의 실제 비용 — 얼마나 느린지 실측한 자료. 남발이 왜 문제인지 근거 확보. Chrome team on text-wrap: balance
  • React reconciliation 과 memoizationReact.memo · useMemo · useCallback 이 실제로 Layout 비용을 줄이는가 (대체로 답: 렌더 트리는 줄이지만 브라우저 Layout 은 안 줄임). React docs — memo
  • Vercel Speed Insights vs Web Analytics — 이 블로그가 쓰는 Web Analytics 는 방문 통계, Speed Insights 는 실사용자 INP · LCP · CLS RUM. 성격 다름. Vercel Speed Insights

— 완벽하게 못 고쳤지만, 문제를 정확히 이해했고 다음에 뭘 할지 안다. 그거면 지금은 충분하다.


Edit page