05. 모바일 발열 잡기

작업일 2026-08-09 · 대상 공개 화면 배경 · 폰트 · 글 상세 캐시 · 결과 공개 화면 애니메이션 0개, 글 상세 TTFB 콜드 3~8초 → 0.24초


1. 증상

핸드폰으로 블로그를 열어 두면 기기가 뜨거워진다. 스크롤하지 않아도, 아무것도 누르지 않아도 그렇다.

이 증상에 대한 점검 목록이 있다. useEffect 무한 루프, 'use client' 남발, 이벤트 리스너 누수, Framer Motion, 런타임 코드 하이라이팅. 하나씩 확인했고 대부분 해당되지 않았다.

흔한 원인 이 코드베이스의 실제
useEffect 무한 리렌더링 없음.
이벤트 리스너 누수 없음. scroll·resize 모두 cleanup 있고 requestAnimationFrame 으로 스로틀
무거운 애니메이션 라이브러리 없음. Framer Motion·GSAP 미사용
런타임 신택스 하이라이팅 없음. highlight.js 는 저장 시점 서버 렌더 경로에만 있다
setInterval · 폴링 · 웹소켓 0개. 공개 화면 전체를 훑어 확인했다
이미지 커버에 loading="lazy" 적용됨

JS 는 범인이 아니었다. requestAnimationFrame 은 두 곳에 있지만 둘 다 scroll·resize 핸들러에서만 예약되므로, 손을 떼면 예약 자체가 일어나지 않는다.

발열은 CSS 와 SVG 필터에서 나왔다.


2. 원인

배경(components/site-shore.jsx + globals.css.shore-*)은 해변 한 장을 그린다. 물, 파도선, 포말, 수면의 빛 굴절, 흐릿한 색 덩어리 네 개. 여기에 세 가지가 겹쳐 있었다.

2.1 난수 필터를 매 프레임 다시 계산

<feTurbulence type="fractalNoise" baseFrequency="0.004 0.009" numOctaves="4" seed="3">
  <animate attributeName="baseFrequency"
           values="0.004 0.009; 0.005 0.008; 0.0035 0.010; 0.004 0.009"
           dur="48s" repeatCount="indefinite" />
</feTurbulence>

baseFrequency 가 계속 바뀌면 브라우저가 결과를 캐시할 수 없다. 매 프레임 전 픽셀의 노이즈를 다시 계산한다. feTurbulence 는 Chrome·Safari 모두 GPU 가속이 없는 CPU 경로다. 다크 모드는 numOctaves="4" — 픽셀당 노이즈 조회 4회에 feGaussianBlur 한 패스가 더 붙는다.

핵심은 스크롤과 무관하게 영구히 돈다는 점이다. 화면을 켜 두기만 해도 CPU 가 쉬지 못한다.

prefers-reduced-motion 도 이걸 못 막았다.

@media (prefers-reduced-motion: reduce) {
  .shore-edge, .shore-foam, .shore-fx, .shore-blobs span { animation: none; }
}

animation: none 은 CSS 애니메이션을 멈춘다. SMIL <animate> 는 CSS 애니메이션이 아니다. 접근성 설정을 켠 사용자도 가장 비싼 연산은 그대로 돌리고 있었다.

2.2 블러 레이어에 scale 애니메이션

.shore-blobs span { filter: blur(90px); will-change: transform; }
.shore-blob-1 { width: 44vw; height: 44vw; animation: shore-drift-a 30s ease-in-out infinite; }

@keyframes shore-drift-a {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(5vw, 3vh, 0) scale(1.1); }
}

translate 만이면 이미 그려 둔 레이어를 합성만 하면 된다. scale 이 섞이면 확대된 해상도로 다시 래스터화해야 하고, 반경 90px 블러를 매번 새로 계산한다. 44vw 짜리가 네 개였다.

2.3 그리고 곱셈 — 움직이는 배경 × 유리판 N장

이게 실제 규모를 만든 부분이다.

.glass {
  backdrop-filter: blur(20px) saturate(175%);
}

backdrop-filter뒤에 있는 것을 흐린다. 뒤가 매 프레임 바뀌면 유리판도 매 프레임 다시 블러해야 한다. 글 목록에서 .glass 는 헤더 1 + 카드 N 개다. 카드 10장이면 프레임당 전면 블러 11회.

정리하면 이렇다.

(CPU 노이즈 영구 생성) × (배경 무효화) × (유리판 수만큼의 backdrop 블러)

여기에 .shore-fxmix-blend-mode: screen.glass::aftermix-blend-mode: overlay 가 합성 고속 경로를 추가로 막았다.


3. 무엇을 고쳤나

3.1 1차 — 난수 재생성만 멈춤 (12dc5b7)

<animate> 두 개를 지웠다. 노이즈는 마운트 시 한 번 생성되어 캐시되고, 흐름은 이미 있던 CSS transform 애니메이션이 낸다. 블롭 keyframes 에서 scale 을 빼고, 모바일(pointer: coarse)에서 코스틱 레이어와 블롭 둘을 내렸다.

이 수정은 절반만 맞았다. 난수 재생성은 멈췄지만 shore-edge·shore-foam·shore-blobs 의 무한 transform 애니메이션은 그대로 돌고 있었다. 배경이 여전히 매 프레임 바뀌므로 2.3절의 곱셈이 살아 있었다.

Chrome Performance 프로파일이 이걸 드러냈다. 22초 녹화에서 Animations 트랙이 끊기지 않고, 메인 스레드가 주기적으로 깨어난다.

Rendering  553ms
System     474ms
Scripting  386ms
Painting   359ms
Total   22,393ms

4. 이어서 나온 것

발열이 정리된 뒤, 같은 프로파일에 남아 있던 다른 항목을 점검했다.

4.1 폰트를 같은 오리진에서 (81fbf02)

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

첫 페인트 전에 외부 오리진 두 곳에 순서대로 붙어야 한다. 그 CSS 를 직접 받아 재 봤다.

CSS 자체            70,613 B
@font-face 규칙        412개   ← 한글이 유니코드 구간별로 잘려 있다
한글 서브셋 조각    11~26 KB/개

어떤 조각을 받을지는 그 412개를 파싱한 뒤에야 정해진다. 연결 두 번과 왕복 한 번이 전부 첫 페인트 앞에 놓인다.

next/font/google 로 빌드 때 받아 자체 호스팅한다.

const notoSansKr = Noto_Sans_KR({
  variable: '--font-noto-sans-kr',
  display: 'swap',
  preload: false,
})

두 가지가 함정이었다.

굵기를 나열하지 않는다. 세 가족 모두 가변 폰트라 한 벌로 400·500·700 을 다 낸다. 굵기를 적으면 굵기마다 파일을 받는다. 이것만으로 @font-face 가 412 → 140개가 됐다.

한글은 서브셋을 고르지 않는다. subsets: ['latin'] 을 주면 한글 글리프가 통째로 빠진다. 대신 preload: false 로 둔다 — 유니코드 구간별로 잘린 100여 개 조각을 전부 preload 하면 첫 화면이 오히려 늦어진다. 브라우저가 본문에 실제로 쓰인 구간만 골라 받는다.

CSS 쪽은 이름 대신 변수를 참조하게 바꿨다. next/font 가 빌드마다 해시 붙은 이름을 짓기 때문이다.

--font-sans: var(--font-inter), var(--font-noto-sans-kr), ui-sans-serif, system-ui, sans-serif;

덤이 하나 있다. next/font 가 폴백 메트릭을 자동으로 붙인다.

@font-face { font-family: "Noto Sans KR Fallback"; src: local(Arial);
  ascent-override: 110.73%; descent-override: 27.49%; size-adjust: 104.76% }

폰트가 도착하는 순간의 레이아웃 이동이 사라진다.

4.2 글 상세를 캐시 (ac52b8a)

04 번 문서에서 조회 결과는 캐시했지만 완성된 HTML 은 캐시하지 않았다. 응답 헤더가 그대로였다.

Cache-Control: private,no-cache,no-store,max-age=0,must-revalidate
Cache-Status: "Netlify Durable"; fwd=bypass

측정하면 이렇다.

글 상세 TTFB:  5.18s / 7.74s / 0.73s      ← 콜드 → 웜
연속 6회 웜:   1.00 → 0.74 → 0.72 → 0.65 → 0.62 → 0.60s
목록:          3.04s → 0.57s

웜은 건강하다. 문제는 엣지에도 브라우저에도 아무것도 남지 않아 재방문도 뒤로가기도 매번 함수를 깨우고, 콜드면 3~8초가 된다는 것이다. 트래픽이 적은 개인 블로그에서는 대부분의 방문이 콜드다.

캐시를 막고 있던 것은 초안이었다.

export const dynamic = 'force-dynamic'   // 세션에 따라 초안이 보인다

const load = cache(async (slug) => getBySlug(slug, { isAdmin: await viewerIsAdmin() }))

viewerIsAdmin() 이 쿠키를 읽고, 읽는 순간 요청마다 달라지는 페이지가 된다.

그 판정을 Next 미리보기 모드 뒤로 옮겼다.

const load = cache(async (slug) => {
  const preview = (await draftMode()).isEnabled
  return getBySlug(slug, { isAdmin: preview && (await viewerIsAdmin()) })
})

미리보기가 꺼져 있으면 쿠키를 아예 건드리지 않는다. 권한은 /api/v1/admin/preview 가 한 번 확인하고 그 사실을 미리보기 쿠키로 옮긴다. 그 쿠키가 붙은 요청만 Next 가 캐시를 건너뛴다.

초안이 새지 않는 근거는 셋이다.

  1. 공개 경로는 isAdmin: false 로 고정 — SQL 에 AND p.status = 'PUBLISHED' 가 붙는다
  2. 미리보기 쿠키는 requireAdmin() 통과 후에만 심고, 값은 Next 가 빌드마다 짓는 난수다
  3. 그 쿠키가 있어도 페이지가 viewerIsAdmin() 을 한 번 더 확인한다 — 미리보기를 켠 뒤 로그아웃했을 수 있다

빌드는 여전히 DB 를 모른다. 04 번 문서에서 전체 라우트 캐시를 보류했던 이유가 이것이었는데, 동적 세그먼트에는 우회로가 있다.

export async function generateStaticParams() {
  return []
}

빈 배열을 주면 빌드 때 아무 글도 굽지 않는다. 글마다 첫 요청에서 한 번 그려지고 그 뒤로는 캐시가 낸다. 무효화는 시간이 아니라 기존 invalidateContent() 태그가 하므로 "저장하면 바로 반영"은 그대로다.

라우트 표에서 확인된다.

├ ƒ /p/[slug]     →     ├ ● /p/[slug]

5. 어떻게 시험했나

5.1 빌드 산출물로 검증

애니메이션 제거는 소스가 아니라 빌드된 CSS 에서 확인했다. 소스에서 지웠어도 Tailwind 나 다른 경로로 들어올 수 있다.

find .next/static -name "*.css" -exec grep -oh "@keyframes [a-zA-Z0-9_-]*" {} \; | sort -u

폰트도 같은 방식이다. 파일 137개, 3.7MB, @font-face 140개, 한글 유니코드 구간 포함 확인.

5.2 캐시가 실제로 붙었는지

curl -s -D - -o /dev/null https://ksh.ai.kr/p/2026-08-08-p5wv2 | grep -i cache
Cache-Control: public,max-age=0,must-revalidate
Cache-Status: "Netlify Edge"; hit; ttl=3583

ttl=3583revalidate = 3600 과 맞는다.

5.3 권한 게이트

curl -s -w "%{http_code}" "https://ksh.ai.kr/api/v1/admin/preview?slug=..."
{"error":{"code":"UNAUTHENTICATED","message":"로그인이 필요합니다."}}
401

캐시된 HTML 에 초안 표식이 섞이지 않는 것도 함께 확인했다.

5.4 회귀

단위 테스트 103개 전부 통과. DB 가 없는 로컬에서 next build 성공 — 빌드가 DB 에 묶이지 않았다는 뜻이다.


6. 전후 비교

배경 연산

이전 이후
feTurbulence 매 프레임 전 픽셀 재계산 (영구) 1회 생성 후 캐시
블롭 애니메이션 translate + scale → 재래스터 + 재블러 없음
공개 화면 무한 애니메이션 8개 0개
backdrop-filter 재계산 프레임당 유리판 수만큼 스크롤 중에만
prefers-reduced-motion SMIL 은 안 멈춤 (버그) 멈출 애니메이션이 없음

폰트

이전 이후
출처 외부 오리진 2곳, 순차 연결 같은 오리진
렌더 블로킹 CSS 70,613 B / 규칙 412개 앱 CSS 에 포함 / 규칙 140개
레이아웃 이동 폴백 메트릭 없음 ascent-override·size-adjust

글 상세 TTFB

이전 이후
콜드 3.0 / 5.2 / 7.7s — (발생하지 않음)
0.60 ~ 0.75s
실측 6회 0.259 / 0.242 / 0.246 / 0.237 / 0.239 / 0.241s

편차가 사라졌다. 다만 이 0.24초를 서버가 빨라진 값으로 읽으면 안 된다. 같은 엣지에서 순수 정적 자산을 받아도 0.247~0.273초가 나온다.

0.24s  네트워크 (이 PC ↔ Netlify 엣지)
0.00s  서버

남은 것은 전부 회선이다. 서버 처리 시간은 사실상 0 이 됐다.