기본값이 바뀌면 가정이 무너진다
성능 최적화에서 가장 위험한 순간은 '아무것도 바꾸지 않았는데 느려질 때'다. Next.js 16이 SaaS 팀에게 던진 충격이 정확히 이것이다. 코드는 그대로인데 대시보드가 느려지고, DB 쿼리가 폭증하고, 어떤 페이지는 갑자기 정확한 데이터를 보여주기 시작한다. dev.to의 분석에 따르면, 그 원인은 단 하나—App Router의 캐싱 기본값이 완전히 역전됐기 때문이다.
이전 Next.js는 '암묵적 캐싱'이 기본이었다. fetch()는 별도 설정 없이 캐시되고, GET 라우트 핸들러도 동적 기능이 없으면 자동으로 캐시됐다. Next.js 16에서는 이 가정이 뒤집혔다. 데이터는 기본적으로 매 요청마다 새로 가져오고, 캐싱은 명시적으로 선언해야 얻을 수 있다. 마케팅 사이트는 이 변화를 거의 체감하지 못했다. SaaS 앱은 달랐다. 라이브 메트릭과 사용자 설정이 혼재하는 대시보드, 역할 기반으로 다르게 렌더링되는 뷰, 어드민 패널—모두 캐싱 가정 위에서 동작하고 있었다.
명시적 캐싱은 부담이 아니라 설계다
새 모델의 핵심 프리미티브는 'use cache' 디렉티브다. 함수 최상단에 선언하면 해당 함수의 반환값이 캐시 대상이 되고, cacheLife로 TTL을, cacheTag로 무효화 키를 지정할 수 있다. 기존에 흩어져 있던 unstable_cache와 fetch 옵션, 라우트 세그먼트 설정이 하나의 패턴으로 통합된 셈이다. 실무적으로 더 중요한 건 태그 기반 무효화다. SaaS 앱에서 TTL만으로 캐시를 관리하면 뮤테이션 직후 낡은 데이터가 노출되는 구조적 위험이 있다. cacheTag로 조직 단위, 유저 단위로 캐시를 태깅하고, 데이터 변경 시점에 revalidateTag를 호출하면 '캐시 히트율'과 '데이터 신선도'를 동시에 확보할 수 있다.
업그레이드 전략으로는 먼저 측정, 그다음 캐싱 변경을 권장한다. p50·p95 대시보드 로딩 타임, 분당 DB 쿼리 수, API 응답 시간을 업그레이드 전에 기록해두지 않으면 "느려진 것 같다"는 체감만 남는다. 업그레이드 직후 캐싱을 아무것도 건드리지 않은 상태가 새로운 베이스라인이다. 이 시점의 DB 부하를 측정한 뒤, 데이터 접근 지점을 '안정적·공개', '안정적·비공개', '신선·공개', '신선·비공개'로 분류해서 각각 적절한 캐싱 전략을 적용하면 된다. 이 분류 작업은 일회성이지만, 한 번 해두면 캐싱 의도가 코드에 명확히 드러나 디버깅 비용이 영구적으로 낮아진다.
성능 측정, '느낌'이 아닌 '구조'로
Next.js 캐싱 문제와 별개로, React 앱의 성능 최적화 자체가 잘못된 순서로 진행되는 경우가 많다. dev.to의 React 성능 가이드는 이 순서 문제를 정면으로 짚는다. useMemo를 여기저기 뿌리고, Lighthouse를 돌리고, 번들을 쪼개도 앱이 여전히 느리다면—측정 대상이 잘못됐을 가능성이 높다.
LCP가 4.2초인데 Lighthouse 이미지 감사는 녹색인 경우가 실제로 존재한다. 히어로 영역에 CSS background-image를 쓰면 next/image가 아예 개입하지 못하고, LCP 요소가 무엇인지 확인하지 않으면 엉뚱한 곳을 최적화하게 된다. LCP 요소를 먼저 특정하고, 그 다음에 롱 태스크를 찾아야 한다. 컴포넌트 트리에는 보이지 않지만 메인 스레드를 50ms 이상 블로킹하는 작업—서드파티 스크립트, 캠페인이 끝난 픽셀 태그, 채팅 위젯—이 INP를 조용히 무너뜨린다. 더 중요한 건 스테이징과 프로덕션의 성능이 다르다는 사실이다. CPU 쓰로틀링, 실제 네트워크 조건, 콜드 캐시 동작을 반영하지 않은 로컬 프로파일링은 절반의 진실만 보여준다. 그리고 성능 회귀는 조용히 찾아온다. CI에 성능 체크를 연결하지 않으면, 의존성 업데이트 하나로 LCP가 3초를 넘어도 유저가 클레임을 걸기 전까지 아무도 모른다.
AI 스트리밍 UI의 숨겨진 병목: 마이크로태스크 포화
세 번째 맹점은 더 새롭고, 더 많은 팀이 아직 인식하지 못하고 있다. AI 챗 인터페이스나 실시간 에이전트 대시보드를 구축할 때 토큰은 빠르게 날아오는데 스크롤이 버벅이거나 '정지' 버튼이 반응하지 않는 경험—이건 UI 라이브러리 문제도, Tailwind 문제도 아니다. 이벤트 루프가 마이크로태스크에 잠식된 것이다.
dev.to의 AI 스트리밍 성능 분석이 이 구조를 명확히 설명한다. AI가 초당 60개의 토큰을 스트리밍하고 매 토큰마다 Promise.resolve() 또는 await로 상태를 업데이트하면, 이벤트 루프의 마이크로태스크 큐가 끝나지 않는다. 브라우저는 다음 프레임을 그리고 싶지만 이벤트 루프는 "아직 마이크로태스크 400개 남았습니다"라고 응답한다. 결과적으로 INP가 200ms 임계값을 훌쩍 넘어서고, 토큰은 빠르게 나타나지만 유저는 앱이 '고장났다'고 느낀다.
해법은 scheduler.yield()다. 기존의 setTimeout(fn, 0)이 태스크를 큐 맨 뒤로 밀어버리는 무딘 도구였다면, Prioritized Task Scheduling API의 scheduler.yield()는 브라우저에게 한 프레임을 양보한 뒤 동일한 우선순위로 작업을 재개한다. 50ms마다 performance.now()로 경과 시간을 체크하고, 초과 시점에 scheduler.yield()를 호출하는 패턴이면 스트리밍 흐름을 끊지 않고도 사용자 인터랙션에 응답할 수 있다. 구형 환경을 위한 setTimeout 폴백도 함께 두면 된다.
세 가지 맹점이 가리키는 하나의 방향
세 가지 이슈—Next.js 16 캐싱 역전, 측정 없는 React 최적화, AI 스트리밍 UI의 이벤트 루프 포화—는 표면적으로 달라 보이지만 같은 실패 패턴에서 비롯된다. 암묵적 가정을 명시적 설계로 전환하지 않은 것이다. 캐싱이 암묵적이면 기본값이 바뀔 때 앱 전체가 흔들린다. 측정 대상이 암묵적이면 엉뚱한 곳을 최적화한다. 이벤트 루프 동작이 암묵적이면 AI 스트리밍이 UI를 삼킨다.
앞으로의 프론트엔드 성능 작업은 '무엇을 최적화하느냐'보다 '무엇을 명시적으로 제어하느냐'의 문제가 될 것이다. React Compiler가 일부 메모이제이션을 자동화하고, AI가 로직을 생성하는 시대일수록—기본값의 의미를 정확히 이해하고, 측정 구조를 설계하고, 실행 흐름을 의도적으로 orchestrate하는 능력이 프론트엔드 엔지니어의 실질적 경쟁력이 된다.