Next.js 체감 속도: 낙관적 업데이트, 스트리밍, 스켈레톤 비교
제조 공정을 관리하는 화면을 만들고 있습니다. 공정 카드마다 「피복 끝 → 가황」 같은 버튼이 있고, 누르면 다음 단계로 넘어갑니다. 써 보다가 걸리는 게 있었습니다. 전에 만든 다른 관리 화면은 누르는 순간 버튼이 바뀌는데, 이 화면은 통신이 끝나야 버튼이 돌아왔습니다.
모든 버튼이 서버를 기다리던 구조
코드를 열어 보니 이 버튼만의 문제가 아니었습니다. 단계 버튼은 누르면 「옮기는 중…」으로 바뀌고 비활성이 됐다가, 서버 액션이 끝나고 페이지가 다시 그려져야 새 상태가 보였습니다. 견적 진행 단계를 고르는 줄은 누르는 동안 통째로 흐려졌고, 직원 재직 스위치는 서버가 답할 때까지 원래 자리에 그대로 있었습니다.
틀린 코드는 아니었습니다. 서버가 확인한 것만 보여 주니 정직하기도 합니다. 다만 서버가 거의 항상 성공하는 동작에서도 매번 왕복 한 번을 기다리게 만들고 있었습니다.
누르는 순간 바꾸고 틀리면 되돌리는 useOptimistic
React 19의 useOptimistic으로 바꿨습니다. 트랜지션 안에서 낙관적 값을 먼저 세우고 서버 액션을 부릅니다. 트랜지션이 끝나면 낙관적 값은 저절로 사라지고 props 값으로 돌아갑니다.
const [, start] = useTransition();
const [done, setDone] = useOptimistic(false);
const run = () =>
start(async () => {
setDone(true); // 바로 「✓ 가황(으)로 넘김」을 그린다
const res = await moveProduction(id, from, "next");
if (!res.ok) setError(res.message); // 트랜지션이 끝나면 done 은 false 로 돌아간다
});이 구조에서 좋았던 점은 되돌리는 코드를 따로 쓰지 않아도 된다는 것입니다. 성공하면 서버 액션이 경로를 다시 검증해 새 props가 같은 트랜지션 안에서 도착하므로, 낙관적 값이 사라져도 화면은 이미 새 상태입니다. 실패하면 새 props가 없으니 원래 버튼으로 돌아가고, 저는 까닭만 적으면 됩니다.
로컬 빌드에서 브라우저로 재 보니 버튼을 누르고 눌림 표시가 뜨기까지 공정 단계는 30ms, 견적 단계 줄은 39ms였습니다. 서버 왕복과 무관한 숫자입니다.
실패를 토스트로 띄웠다가 성공처럼 보인 실수
하나 틀린 것이 있었습니다. 상품 노출 스위치는 이미 낙관적으로 되어 있었는데, 실패하면 「바꾸지 못했습니다」를 토스트로 띄웠습니다. 쓰던 UI 라이브러리의 토스트는 체크 아이콘이 붙은 성공 모양 하나뿐이었습니다. 실패 문구가 초록 체크와 함께 떴다 사라지는 셈이었습니다.
낙관적 업데이트에서는 이게 특히 나쁩니다. 스위치는 이미 되돌아가 있는데 알림은 성공처럼 보이니, 사용자는 무엇이 맞는지 알 수 없습니다. 실패는 스위치 옆에 빨간 글자로 「바꾸지 못함」을 적는 쪽으로 바꿨습니다.
저장 버튼에는 쓰지 않은 판단
직원, 고객사, 상품을 저장하는 버튼은 그대로 「저장 중…」을 보여 줍니다. 이 버튼들은 서버가 입력을 검사해서 틀린 칸을 돌려주는 곳입니다. 아이디가 겹치거나 이메일 형식이 틀리면 거절됩니다.
낙관적 업데이트는 서버가 거의 항상 "예"라고 할 때만 이득입니다. 거절이 흔한 곳에서 미리 성공을 보여 주면, 저장됐다고 보였다가 몇백 ms 뒤 빨간 칸이 뜨며 뒤집힙니다. 기다리게 하는 편보다 그쪽이 더 불쾌하다고 봅니다.
버튼 다음에 보인 페이지 이동 문제
버튼을 고치고 나니 다음 질문이 스켈레톤이었습니다. 확인해 보니 이 앱에는 loading.tsx가 하나도 없었습니다.
관리 화면은 요청마다 DB를 읽는 동적 페이지입니다. Next.js 16 문서의 prefetching 가이드에 따르면 Cache Components를 쓰지 않을 때 동적 라우트는 loading.js 경계가 없으면 미리 받지 않습니다. 그래서 메뉴를 누르면 서버가 다 그릴 때까지 이전 화면이 그대로 남습니다. 버튼에서 본 것과 같은 증상이 페이지 단위로 나고 있었던 것입니다.
느린 화면을 다루는 세 갈래
스켈레톤 말고 다른 방법이 있는지 찾아보니 꽤 많았습니다. 그런데 나란히 놓고 보면 서로를 대신하는 관계가 아니었습니다. 하는 일이 셋으로 갈립니다.
| 갈래 | 기법 | 바뀌는 것 |
|---|---|---|
| 실제로 빠르게 | 쿼리 수 줄이기, 순서대로 돌던 쿼리를 동시에, use cache |
기다리는 시간 자체 |
| 먼저 보여 주기 | Suspense 스트리밍, prefetch | 전체 시간은 같고 일부가 먼저 뜸 |
| 눌렸다고 알리기 | 낙관적 업데이트, useLinkStatus, loading.tsx 스켈레톤 |
시간은 그대로, 멈췄다는 느낌이 사라짐 |
useLinkStatus는 누른 <Link>가 이동 중인지를 알려 주는 훅이라, 사이드바 메뉴 옆에 작은 표시 하나만 달면 됩니다. 문서도 동적 라우트에 loading.js가 없을 때 쓰라고 권합니다.
스켈레톤은 loading.tsx 파일 하나로 끝나는데, 하는 일이 둘입니다. 누르는 즉시 틀을 보여 주고, 그 경계까지를 미리 받게 해 줍니다. 앞 절의 prefetch 규칙 때문입니다.
셋째 갈래부터 손대면 생기는 일
제가 보기에 순서가 중요합니다. 서버가 1초 걸리는 페이지에 스켈레톤만 붙이면 사용자는 1초짜리 회색 틀을 보게 됩니다. 반대로 서버가 100ms 안에 그리는 페이지에 스켈레톤을 붙이면 틀이 한 번 번쩍이고 사라질 뿐이고, 그건 없던 불편을 만드는 것입니다.
버튼은 셋째 갈래로 충분했습니다. 서버 왕복을 줄일 방법이 없는 동작이고, 결과를 미리 알 수 있기 때문입니다. 페이지 이동은 다릅니다. 어느 페이지가 얼마나 걸리는지 모르면 어느 갈래가 필요한지 정할 수 없습니다.
솔직히 말하면 이 글을 쓰는 지금까지 운영 환경에서 페이지별 서버 시간을 재지 않았습니다. 기법을 늘어놓다가 그걸 빠뜨렸다는 것을 알았습니다. 다음 순서는 페이지마다 서버 시간을 재고, 빠른 곳에는 useLinkStatus만, 느린 곳은 스트리밍이나 쿼리 정리를 먼저 하고, 그래도 느린 화면에만 스켈레톤을 붙이는 것입니다.
함께 읽기
- Next.js 병렬 라우트와 인터셉트 라우트: 모달 패턴과 default.js사진 목록에서 사진을 누르면 모달로 크게 보여 주고, 같은 주소를 새 탭에서 열면 전체 페이지로 보여 주는 화면이 있습니다. 주소는 /photo/123 하나인데 보이는 모양이 둘입니다.
- Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.
- Next.js 다국어 경로 설계: 쿠키와 하위 경로공개하지 않고 내부에서만 여는 제품 카탈로그 데모에 언어 전환을 붙였습니다. 한국어, 영어, 중국어, 일본어, 독일어, 프랑스어 여섯 개입니다. 고른 언어는 쿠키에 저장하고, 쿠키가 없으면 브라우저의 Accept-Language 헤더를 봅니다. 주소는 나누지 않았습니다. /products/<기종> 하나로 여섯 언어를 모…
- Next.js 라우트 핸들러와 서버 함수 구분앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.
- Next.js 이미지 최적화: next/image와 빌드 때 굽기제품 카탈로그 데모를 만들면서 이미지 처리 방식을 정해야 했습니다. 화면에 나가는 사진은 제품 사진 몇 장과 로고뿐이고, 모두 저장소에 들어 있는 파일입니다. 요청마다 달라질 것이 없습니다.