잘 돌아가는 앱과, 믿음직한 앱은 다릅니다
'작은 기록 상점'은 이제 제법 앱처럼 보입니다. 목록도 있고, 상세 페이지도 있고, 입력도 됩니다. 그런데 여기서 한 단계 더 올라가려면 아주 중요한 질문을 해야 합니다.
이 앱은 빠를까요?
느릴 때도 친절할까요?
문제가 생겨도 사용자를 버리지 않을까요?
실무에서는 이 차이가 큽니다. 화면이 보이기만 하면 끝이 아니라, 기다리는 순간에도 덜 답답해야 하고, 잘못된 주소에 들어갔을 때도 길을 안내해야 하며, 잠깐 오류가 났을 때도 "끝났습니다" 대신 "다시 해볼까요?"라고 말해줘야 합니다.
이번 장에서는 바로 그 품질을 챙깁니다. 캐싱과 리밸리데이션을 익혀서 빠르게 만들고, loading.tsx, error.tsx, not-found.tsx를 붙여서 사용자 경험을 한층 부드럽게 다듬어 보겠습니다.
캐싱은 냉장고, 리밸리데이션은 유통기한 확인입니다
캐싱은 한 번 만든 결과를 저장해 두었다가, 다음 요청에서 다시 꺼내 쓰는 방식입니다. 같은 일을 매번 처음부터 하지 않으니 더 빠릅니다.
리밸리데이션은 저장해 둔 결과를 언제 새것으로 바꿀지 정하는 과정입니다. 오래된 빵을 영원히 진열해 두면 안 되듯이, 캐시도 적절한 시점에 새로 확인해야 합니다.1
초보자라면 우선 세 가지만 기억하면 충분합니다.
캐시한다는 것은 같은 결과를 다시 재사용한다는 뜻입니다.
리밸리데이션한다는 것은 저장된 결과를 다시 확인하고 갱신한다는 뜻입니다.
no-store는 아예 저장하지 않고 매번 새로 가져오겠다는 뜻입니다.
이 감각만 잡히면 복잡해 보이던 캐싱도 꽤 말이 통하기 시작합니다.
최신 Next.js에서 먼저 기억할 한 가지
예전 글을 보다 보면 "fetch는 기본적으로 캐시된다"거나 "GET Route Handler는 기본적으로 정적이다" 같은 설명을 자주 만납니다. 하지만 최신 Next.js 기준에서는 그렇게 단순하게 외우면 오히려 헷갈릴 수 있습니다.
초보자에게는 이렇게 기억하는 편이 가장 안전합니다.
fetch는 내가 직접cache나revalidate를 정해 주는 습관이 좋습니다.Route Handler의
GET도 기본적으로 캐시를 기대하지 않는 편이 안전합니다."이 화면은 얼마나 자주 바뀌는가?"를 먼저 생각하고 설정을 고릅니다.12
즉, 캐시는 "프레임워크가 알아서 다 해주겠지"보다 내가 의도를 적어 주는 쪽으로 이해하면 훨씬 덜 헷갈립니다.
우리 앱에 어떤 전략이 어울릴까요?
'작은 기록 상점'의 모든 데이터가 똑같이 움직이지는 않습니다.
홈 소개 문구나 카테고리 안내처럼 거의 바뀌지 않는 내용은 오래 캐시해도 괜찮습니다. 기록 목록은 자주 바뀌진 않지만 너무 오래된 것도 싫으니 몇십 초에서 몇 분 정도 재검증하면 좋습니다. 반면 방금 수정한 내 기록 상세 화면은 가능한 한 빨리 새 데이터를 보여주는 편이 자연스럽습니다.
이렇게 생각하면 전략이 보입니다.
기록 목록: 적당히 캐시하고 주기적으로 갱신
기록 상세: 상황에 따라 짧게 캐시하거나, 변경 직후 바로 갱신
관리자 화면이나 개인화 데이터: 필요하면
no-store
즉, "모든 페이지를 다 똑같이 캐시한다"가 아니라, 데이터 성격에 맞춰 보관법을 다르게 정한다가 핵심입니다.
목록 페이지를 더 똑똑하게 캐시하기
먼저 기록 목록은 여러 사용자가 자주 보게 되는 공용 데이터입니다. 이럴 때는 fetch에 next.revalidate를 주면 좋습니다. 일정 시간 동안은 저장된 결과를 사용하고, 그 시간이 지나면 다음 요청에서 새 데이터를 확인하는 방식입니다.1
lib/records.ts를 이런 느낌으로 다듬어 볼 수 있습니다.
const BASE_URL = 'https://jsonplaceholder.typicode.com'
type PostResponse = {
userId: number
id: number
title: string
body: string
}
export async function fetchRecordList() {
const res = await fetch(`${BASE_URL}/posts?_limit=12`, {
next: { revalidate: 60 },
})
if (!res.ok) {
throw new Error('기록 목록을 불러오지 못했습니다.')
}
const posts: PostResponse[] = await res.json()
return posts.map((post) => ({
id: post.id,
title: post.title,
summary: post.body.slice(0, 80) + '...',
}))
}여기서 revalidate: 60은 "최대 60초까지는 저장된 결과를 재사용해도 괜찮다"는 뜻입니다.1
쉽게 말하면 이렇습니다.
기록 목록은 카페 메뉴판과 비슷합니다. 손님이 들어올 때마다 새로 인쇄할 필요는 없습니다. 하지만 가격이 바뀌었다면 언젠가는 새 메뉴판으로 갈아줘야 하죠.
항상 최신이어야 한다면 no-store
반대로 어떤 데이터는 "방금 바뀐 내용이 지금 보여야 한다"가 더 중요할 수 있습니다. 이럴 때는 cache: 'no-store'를 사용합니다.1
export async function fetchRecordDetail(id: string) {
const res = await fetch(`https://example.com/api/records/${id}`, {
cache: 'no-store',
})
if (!res.ok) {
throw new Error('기록 상세를 불러오지 못했습니다.')
}
return res.json()
}이 옵션은 "매 요청마다 새로 가져와 주세요"에 가깝습니다.
다만 초보자에게는 한 가지 기준이 있으면 좋습니다.
모든 요청에 무조건 no-store를 붙이지는 말자.
최신성은 좋아지지만, 매번 새로 가져오니 속도와 비용 면에서는 손해일 수 있습니다. 꼭 필요한 화면에만 쓰는 편이 균형이 좋습니다.
수정 직후 바로 반영하려면 revalidatePath()
주기적 갱신만으로 충분할 때도 있지만, 어떤 순간에는 "지금 바꾼 내용을 지금 보이고 싶다"가 더 중요합니다. 예를 들어 사용자가 기록 제목을 수정했는데 목록 화면엔 아직 예전 제목이 남아 있다면, 조금 어색하겠죠.
이럴 때는 온디맨드 리밸리데이션을 사용합니다. 말이 조금 어려워 보이지만 뜻은 단순합니다.
"일정 시간 기다리지 말고, 데이터가 바뀌는 바로 그 순간 다시 준비해."
Server Action에서 revalidatePath()를 호출하면 경로 기준으로 다시 갱신할 수 있습니다.2
예를 들어 기록 수정 액션은 이렇게 쓸 수 있습니다.
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updateRecord(id: string, formData: FormData) {
const title = String(formData.get('title') ?? '')
const content = String(formData.get('content') ?? '')
// 실제 프로젝트라면 여기서 DB 저장 또는 API 요청 수행
// await db.record.update({ where: { id }, data: { title, content } })
revalidatePath('/records')
revalidatePath(`/records/${id}`)
redirect(`/records/${id}`)
}이 코드는 두 군데를 새로 보게 만듭니다.
하나는 목록 페이지이고, 다른 하나는 방금 수정한 상세 페이지입니다. 그래서 사용자는 "분명 저장했는데 왜 아직 옛날 내용이지?"라는 찜찜함을 덜 겪게 됩니다.
router.refresh()와 revalidatePath()는 역할이 다릅니다
이 부분은 정말 많이 헷갈립니다.
router.refresh()는 현재 경로를 다시 읽게 도와주는 클라이언트 쪽 동작입니다.revalidatePath()는 서버 쪽에서 해당 경로의 캐시를 다시 준비하도록 알려주는 동작입니다.2
둘은 비슷해 보여도 같은 도구가 아닙니다.
초보자 기준으로는 이렇게 구분하면 편합니다.
사용자가 지금 보고 있는 화면을 다시 읽고 싶다 →
router.refresh()데이터가 바뀌었으니 특정 경로의 결과를 새것으로 만들고 싶다 →
revalidatePath()
즉, 하나는 다시 불러오기, 다른 하나는 다시 준비하기에 가깝습니다.
stale이라는 말을 너무 무서워하지 마세요
리밸리데이션을 이해할 때 함께 나오는 말이 있습니다. 바로 stale입니다. 우리말로 하면 "살짝 오래된 상태"쯤으로 생각하면 됩니다.
이 말이 중요한 이유는, 캐시 전략에서는 어떤 순간에 약간 오래된 데이터를 잠깐 보여주는 편이 체감 속도에 더 유리할 수 있기 때문입니다.
즉, stale은 꼭 나쁜 단어가 아닙니다.
빵집에서 막 나온 식빵이 가장 좋긴 하지만, 방금 전 시간에 구운 빵도 충분히 맛있다면 먼저 건네고 뒤에서 새 빵을 준비하는 식입니다.
다만 결제 상태, 권한 정보, 개인 대시보드처럼 "한 박자 늦어도 곤란한 데이터"라면 이런 방식보다 no-store가 더 잘 어울릴 수 있습니다.
loading.tsx로 기다림을 덜 심심하게 만들기
이제 속도를 챙겼다면, 기다림의 표정도 바꿔봅시다.
페이지 이동 중 데이터가 준비되기 전까지 아무것도 안 보이면 사용자는 앱이 멈췄다고 느끼기 쉽습니다. loading.tsx는 바로 그 짧은 공백을 메워주는 파일입니다. App Router에서는 해당 세그먼트에 loading.tsx를 두면, 페이지가 준비되는 동안 자동으로 로딩 UI를 보여줄 수 있습니다.3
기록 목록에 먼저 붙여 보겠습니다.
// app/records/loading.tsx
export default function Loading() {
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="mb-8">
<div className="h-9 w-56 animate-pulse rounded bg-stone-200" />
<div className="mt-3 h-5 w-72 animate-pulse rounded bg-stone-100" />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm"
>
<div className="h-6 w-24 animate-pulse rounded-full bg-stone-100" />
<div className="mt-4 h-6 w-3/4 animate-pulse rounded bg-stone-200" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-stone-100" />
<div className="mt-2 h-4 w-5/6 animate-pulse rounded bg-stone-100" />
</div>
))}
</div>
</main>
)
}이 화면은 진짜 데이터 대신 뼈대만 먼저 보여줍니다. 이런 형태를 흔히 스켈레톤 UI라고 부릅니다. 어려운 말 같지만 그냥 "완성되기 전 밑그림"이라고 생각하면 됩니다.
상세 페이지에도 붙일 수 있습니다.
// app/records/[id]/loading.tsx
export default function Loading() {
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<div className="h-5 w-28 animate-pulse rounded bg-stone-100" />
<div className="mt-6 rounded-3xl border border-stone-200 bg-white p-8 shadow-sm">
<div className="h-6 w-24 animate-pulse rounded-full bg-amber-100" />
<div className="mt-5 h-10 w-4/5 animate-pulse rounded bg-stone-200" />
<div className="mt-6 space-y-3">
<div className="h-5 w-full animate-pulse rounded bg-stone-100" />
<div className="h-5 w-11/12 animate-pulse rounded bg-stone-100" />
<div className="h-5 w-4/5 animate-pulse rounded bg-stone-100" />
</div>
</div>
</main>
)
}중요한 건 화려함이 아닙니다. 사용자가 "아, 지금 열심히 불러오는 중이구나"를 느낄 수 있으면 됩니다.
error.tsx로 실패를 품위 있게 처리하기
아무리 조심해도 오류는 생깁니다. 네트워크가 잠깐 흔들릴 수도 있고, 외부 API가 늦잠을 잘 수도 있습니다. 이때 흰 화면이나 차가운 에러 문구만 보여주면 사용자는 당황합니다.
App Router에서는 라우트 세그먼트에 error.tsx를 두면 그 구간에서 발생한 예기치 않은 오류를 잡아서 대체 UI를 보여줄 수 있습니다. 이 파일은 클라이언트 컴포넌트여야 하고, error 객체와 reset() 함수를 받습니다.4
기록 목록 라우트에 에러 화면을 만들어 보겠습니다.
// app/records/error.tsx
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<main className="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center px-6 text-center">
<p className="text-sm font-semibold text-amber-700">앗, 기록 상점에 잠깐 문제가 생겼어요.</p>
<h1 className="mt-3 text-3xl font-bold text-stone-900">
진열대를 다시 정리해볼게요
</h1>
<p className="mt-4 text-stone-600">
네트워크가 잠깐 흔들렸거나, 데이터를 불러오는 중 문제가 생겼을 수 있어요.
</p>
<div className="mt-8 flex gap-3">
<button
onClick={() => reset()}
className="rounded-full bg-stone-900 px-5 py-3 text-sm font-semibold text-white hover:bg-stone-700"
>
다시 시도하기
</button>
<Link
href="/"
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 hover:bg-stone-50"
>
홈으로 가기
</Link>
</div>
</main>
)
}여기서 reset()은 에러 상태를 초기화하고 다시 렌더링을 시도하게 도와줍니다.4
한마디로 정리하면 이렇습니다.
error.tsx는 "문제가 생겼습니다"에서 끝내지 않고, 사용자에게 복구 버튼을 건네는 파일입니다.
not-found.tsx는 길을 잃은 사용자를 위한 표지판입니다
오류와 404는 닮았지만 다릅니다.
오류는 "뭔가 예상치 못한 문제가 생겼다"에 가깝고, 404는 "찾으려는 것이 원래 없다"에 가깝습니다. 존재하지 않는 기록을 열었는데 에러 화면을 보여주면, 사용자는 서비스가 고장 났다고 오해할 수 있습니다.
그래서 이런 상황엔 notFound()와 not-found.tsx를 씁니다. App Router에서는 존재하지 않는 경로에 대한 전역 404도 만들 수 있고, 특정 라우트 안에서만 쓰는 로컬 404도 만들 수 있습니다.5
기록 상세 페이지에서 이미 이런 흐름을 쓸 수 있습니다.
// app/records/[id]/page.tsx
import { notFound } from 'next/navigation'
import { fetchRecordDetail } from '@/lib/records'
type Props = {
params: Promise<{ id: string }>
}
export default async function RecordDetailPage({ params }: Props) {
const { id } = await params
const record = await fetchRecordDetail(id)
if (!record) {
notFound()
}
return <div>{record.title}</div>
}그리고 해당 라우트에 not-found.tsx를 두면 더 친절한 화면을 보여줄 수 있습니다.
// app/records/[id]/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<main className="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center px-6 text-center">
<p className="text-sm font-semibold text-rose-600">404</p>
<h1 className="mt-3 text-3xl font-bold text-stone-900">
찾으시는 기록이 보이지 않아요
</h1>
<p className="mt-4 text-stone-600">
이미 사라졌거나, 주소가 잘못되었을 수 있어요. 목록으로 돌아가 다른 기록을 골라보세요.
</p>
<Link
href="/records"
className="mt-8 rounded-full bg-stone-900 px-5 py-3 text-sm font-semibold text-white hover:bg-stone-700"
>
기록 목록으로 가기
</Link>
</main>
)
}이런 페이지는 생각보다 중요합니다. 길을 잃은 사용자를 그냥 놓아버리지 않고, 다시 정상 경로로 데려오기 때문입니다.
error.tsx와 not-found.tsx를 헷갈리지 않는 법
처음에는 둘이 꽤 비슷해 보입니다. 하지만 역할은 분명히 다릅니다.
데이터가 잠깐 실패했거나 예기치 않은 문제가 생겼다면
error.tsx요청한 기록이 애초에 존재하지 않는다면
not-found.tsx로딩 중 빈 화면 대신 진행 상태를 보여주고 싶다면
loading.tsx
이 셋은 서로 경쟁하는 관계가 아니라, 각자 다른 순간을 맡는 팀원입니다.
초보자가 자주 놓치는 실전 포인트
여기부터가 정말 중요합니다.
첫째, loading.tsx는 느린 앱을 숨기는 마법이 아닙니다. 속도 자체를 개선하는 건 캐싱과 데이터 구조이고, loading.tsx는 기다리는 감정을 다듬는 도구입니다. 둘은 함께 써야 진짜 힘이 납니다.
둘째, 개발 모드와 실제 배포 환경은 캐시 체감이 다를 수 있습니다. 개발 중에는 "왜 바로 안 바뀌지?" 또는 "왜 매번 새로 불러오지?" 같은 혼란이 생길 수 있으니, 캐시 문제는 가능하면 배포에 가까운 환경에서도 확인하는 습관이 좋습니다.
셋째, 데이터가 바뀌는 기능을 만들었다면 revalidatePath() 같은 갱신 전략까지 같이 설계해야 합니다. 저장은 성공했는데 화면만 예전 상태로 보이면 사용자는 쉽게 불안해집니다.
최신 Next.js에는 더 세밀한 캐시 도구도 있습니다
최신 Next.js 문서를 보다 보면 use cache, cacheLife, cacheTag 같은 더 세밀한 캐시 도구도 보게 됩니다. 이런 도구들은 함수나 컴포넌트 단위로 캐시를 더 정교하게 다루고 싶을 때 유용합니다.1
다만 초보자에게는 한 번에 너무 많은 손잡이를 쥐어주면 오히려 헷갈릴 수 있습니다. 그래서 지금 단계에서는 다음 순서가 가장 좋습니다.
먼저 fetch의 revalidate, cache: 'no-store', revalidatePath()에 익숙해집니다.
그다음 프로젝트가 커지면서 "이 함수 결과 자체를 캐시하고 싶다"거나 "더 세밀하게 다루고 싶다"는 필요가 생기면 그때 확장해도 충분합니다.
즉, 계단을 한 칸씩 밟아 올라가면 됩니다. 갑자기 점프할 필요는 없습니다.
오늘 우리가 만든 변화는 작아 보여도 큽니다
이번 장에서 붙인 것들은 겉으로는 소소해 보일 수 있습니다.
목록을 주기적으로 다시 확인하게 한 것, 수정 후 경로를 다시 준비하게 한 것, 로딩 뼈대를 보여준 것, 에러 화면에 다시 시도 버튼을 넣은 것, 없는 기록에 404 화면을 준비한 것.
그런데 바로 이런 조각들이 앱의 인상을 바꿉니다.
빠른 앱은 사용자를 기쁘게 하고, 친절한 앱은 사용자를 안심시킵니다.
그리고 그 둘이 합쳐질 때 비로소 "잘 만든 앱 같다"는 느낌이 납니다.
마무리
이번 장의 핵심은 한 문장으로 정리할 수 있습니다.
실전 품질은 거대한 기술 하나가 아니라, 작은 배려 여러 개가 쌓여서 만들어집니다.
캐싱은 속도를 챙기고, 리밸리데이션은 최신성을 챙깁니다. loading.tsx는 기다림을 덜 불안하게 만들고, error.tsx는 실패 후의 복구를 돕습니다. not-found.tsx는 길을 잃은 사용자를 다시 데려옵니다.
이제 '작은 기록 상점'은 단지 기능이 있는 앱이 아니라, 사용자를 배려할 줄 아는 앱에 조금 더 가까워졌습니다.
다음 장에서는 여기서 한 걸음 더 나아가, 검색엔진과 성능까지 챙기는 방법을 살펴보겠습니다. 겉모습이 아닌, 앱의 체력까지 다듬는 시간입니다.
참고
1Getting Started: Caching and Revalidating | Next.js
2Functions: revalidatePath | Next.js
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.