Repository navigation
Project‐Troubleshooting
개발 중 겪은 문제와 원인, 해결 방법을 기록한다. 같은 문제를 다시 겪지 않기 위한 메모다.
주문 내역에서 주문 상세로 들어갔다가 다시 주문 내역으로 돌아오면,
- 돌아오는 데 시간이 오래 걸리고
- 제목("주문 내역")과 "데모 회원님의 주문 N건" 문구까지 화면 전체가 사라졌다가 다시 그려졌다.
사용자에게는 화면이 순간적으로 멈추고 깨지는 것처럼 보인다. 바뀌는 것은 주문 데이터뿐인데, 데이터와 상관없는 제목까지 다시 그려지는 것이 문제였다.
근본 원인: Suspense 범위를 너무 넓게 잡았다.
주문 내역 페이지 전체(제목 + 건수 + 필터 + 카드)를 <Suspense> 하나로 감싸고, 로딩 중에는 카드 모양 스켈레톤만 보여 주었다. 데이터와 상관없는 제목 · 문구까지 "데이터를 기다리는 영역" 안에 들어가 있어서, 이 화면에 들어올 때마다(어떤 경로로 오든) 고정된 부분까지 fallback으로 바뀌었다가 다시 그려졌다.
변경 전
<Suspense fallback={카드 스켈레톤}> ← 페이지 전체가 여기 들어 있음
제목 · "데모 회원님의 주문 N건" · 필터 · 카드
</Suspense>
Next.js(App Router + Cache Components)에서 "미리 그려 두고 유지되는 부분"과 "요청 때 데이터를 가져와 채우는 부분"의 경계는 Suspense 위치로 정해진다. 경계를 페이지 전체로 잡으면 서버 렌더링 화면인데도 매번 통째로 다시 그리는 것처럼 보인다. 고정된 UI는 그대로 두고 데이터만 가져오는 것이 SSR과 CSR 사이에서 중요한 이유다.
증상을 키운 요인: 돌아가기 링크가 redirect를 한 번 더 거쳤다.
주문 상세의 "← 마이페이지로" 링크가 /mypage로 가고, 로그인 상태의 /mypage가 다시 /mypage/orders로 이동(redirect)했다. 화면을 두 번 거치면서 멈춤이 더 길고 눈에 띄었다. 다만 이것만 고쳤다면 들어올 때마다 고정된 부분이 깨지는 현상은 그대로 남는다.
① (근본 해결) 데이터가 필요한 부분만 Suspense로 나눈다. 화면 틀(제목, "님의 주문 …건" 문구)은 Suspense 바깥에 두어 미리 그려진 화면(static shell)에 포함시키고, 데이터가 필요한 곳만 각각 나중에 채운다.
변경 후
제목 "주문 내역" ← 미리 그려진 틀 (데이터 없음, 유지됨)
"데모 회원님의 주문 [N]건"
└ <Suspense> 건수 숫자만
<Suspense fallback={필터 + 카드 자리}> ← 실제와 같은 높이의 자리 표시
상태 필터 · 주문 카드
</Suspense>
- 빌드 결과도
/mypage/orders가 부분 사전 렌더링(Partial Prerender, ◐) 으로 바뀌었다. 화면 틀은 미리 만들어 두고, 데이터 부분만 요청 때 채운다. - 자리 표시(fallback)는 실제 필터 · 카드와 같은 높이로 두어, 데이터가 들어올 때 아래 내용이 밀리지 않게 했다.
② (보조) 돌아가기 링크를 목적지로 바로 보낸다.
회원(로그인 상태)은 "← 주문 내역으로" 링크로 /mypage/orders에 바로 이동한다. 비회원 조회로 들어온 경우만 기존처럼 "← 마이페이지로"를 쓴다. 불필요한 이동이 없어져 화면을 한 번만 거친다.
③ (보조) 주문 목록을 캐시한다.
주문 목록 조회를 'use cache' + cacheTag('orders')로 캐시했다. 매번 DB를 읽지 않고, 주문이 생길 때(주문 생성 Server Action) updateTag('orders')로 갱신한다. 관리자 화면에서 주문 상태를 바꾸는 Server Action도 같은 태그를 갱신해야 한다.
상세 → 주문 내역 이동을 3번 반복해 측정했다 (헤드리스 Chrome, 25ms 간격 기록).
- 이동 경로:
상세 → /mypage → /mypage/orders(두 번 이동)에서상세 → /mypage/orders(한 번)로 줄었다. - 카드가 보이기까지 약 84ms (개발 서버 기준)
- 이동 중 제목이 사라진 순간: 0회
같은 원인(페이지 전체를 Suspense 하나로 감쌈)이 다른 화면에도 있어 쇼핑몰 전체를 다시 나눴다. 원칙은 고정된 문구 · 틀은 Suspense 바깥, 주소 값 · 쿠키 · DB가 필요한 곳만 안쪽이다.
| 화면 | 바깥 (바로 그림) | 안쪽 (데이터가 오면 채움) |
|---|---|---|
| 주문 내역 | 제목 "주문 내역", "데모 회원님의 주문 …건" 문구 | 건수 숫자, 상태 필터 + 주문 카드 |
| 주문 상세 | 제목 "주문 상세", 돌아가기 줄 자리 | 돌아가기 링크(로그인 여부), 상태 뱃지, 주문번호 · 일시, 본문 |
| 마이페이지 | 화면 전체 (로그인 카드 · 비회원 조회) | 로그인 상태 확인 → 로그인이면 주문 내역으로 이동 |
| 상품 목록 | 제목 띠 · 목록 영역의 틀 | 제목 · 설명(주소 값), 필터 · 건수 · 정렬 · 카드 |
| 주문 완료 | 아래 버튼(쇼핑 계속하기 · 홈으로) | 완료 / 주문 없음 안내와 주문 요약 |
- 한 화면에서 여러 조각이 같은 데이터를 쓰면 React
cache()로 요청당 한 번만 조회한다(주문 상세: 뱃지 · 주문 정보 · 본문). - 측정(개발 서버, 25ms 간격): 주문 내역 ↔ 상세 이동 · 홈 → 카테고리 이동 모두 도착 후 제목이 사라진 순간 0회. 데이터를 기다리는 동안에는 본문 자리만 스켈레톤으로 보인다.
- 상품 상세는 제목이 상품 이름(데이터)이고, 빌드 때 모든 상품을 미리 그려 두므로(generateStaticParams) 지금 구조를 유지했다.
- Suspense 경계는 "데이터가 필요한 가장 작은 단위"로 둔다. 페이지 전체를 감싸면 데이터와 상관없는 부분까지 로딩 화면이 된다. 이것이 이번 문제의 근본 원인이었다.
- 증상을 줄이는 수정(redirect 제거)과 원인을 없애는 수정(경계 재설계)을 구분한다. 앞의 것만 하면 같은 문제가 다른 경로에서 다시 나온다.
- 링크는 최종 목적지로 바로 보낸다. 중간에 redirect를 거치면 사용자는 화면을 두 번 보게 된다.
- 자리 표시는 실제 내용과 같은 크기로 만든다. 크기가 다르면 데이터가 들어올 때 화면이 밀린다.
마이페이지 아이콘에 로그인 드롭 메뉴를 붙인 뒤, 주문 상세(/mypage/orders/[orderNo])에서 서버 로그에 다음 에러가 났다. 브라우저 콘솔에는 보이지 않고 서버 로그에만 찍혔다.
Error: Route "/mypage/orders/[orderNo]": Next.js encountered URL data `usePathname()`
in a Client Component outside of `<Suspense>`.
로그인 상태(쿠키)는 요청 때만 알 수 있어 아이콘을 <Suspense>로 감쌌는데, 로딩 중에 보여 줄 대체 아이콘(fallback)이 현재 주소를 읽는 컴포넌트(usePathname)를 그대로 썼다. fallback은 Suspense 경계 바깥에서 그려지므로, Next.js 16(Cache Components)에서는 주소를 읽는 코드가 경계 밖에 있는 것으로 판단해 사전 렌더링을 막는다.
fallback을 주소를 읽지 않는 단순한 링크(AccountMenuFallback)로 분리했다. 실제 아이콘(현재 위치 표시, 로그인 시 드롭 메뉴)은 Suspense 안에서만 그린다.
-
Suspense의 fallback도 경계 바깥 코드다. fallback에는
usePathname·useSearchParams처럼 요청 시점 값을 읽는 컴포넌트를 넣지 않는다. - 화면 확인은 브라우저 콘솔뿐 아니라 서버 로그와
pnpm build까지 본다.
새 DB로 처음 배포했더니 두 가지 오류가 연달아 났다.
-
The table public.Product does not exist(P2021) — 앱이 상품을 조회하는데 테이블이 없다. - 테이블을 만든 뒤에도
all generateStaticParams functions must return at least one result로 빌드 실패 — 상품이 0개다.
-
테이블 없음: 로컬에서는
prisma migrate dev로 DB를 만들었는데, Vercel 빌드(next build)는 마이그레이션을 실행하지 않는다. 배포 DB는 비어 있었다. -
빌드 실패: 상품 상세는 빌드할 때 DB의 상품 목록으로 페이지를 미리 만든다(
generateStaticParams). Next.js 16(Cache Components)은 이 목록이 비면 빌드를 거부한다. "상품이 항상 있다"는 전제로 짠 코드였고, 시드 전의 새 DB가 이 전제를 깼다. 로컬에서 상품을 전부 지워도 똑같이 재현됐다.
-
package.json에vercel-build스크립트(prisma migrate deploy && next build)를 추가했다. Vercel은 이 스크립트가 있으면build대신 실행해 배포마다 마이그레이션을 먼저 적용한다. 로컬 · CI의pnpm build는 그대로라 DB를 바꾸지 않는다. (migrate dev가 아니라 운영용migrate deploy) - 상품이 없으면
generateStaticParams가 자리표시 주소(_) 하나를 돌려준다. 이 주소는 404가 되고 실제 상품에는 영향이 없다. 상품이 있으면 기존과 같다. 홈의 "가장 많이 찾는 빵" 구역은 비어 있을 때 안내 문구를 보여 준다. - 시드는 자동으로 돌리지 않고 최초 한 번 직접 넣는다(중복 방지).
- 빈 DB(마이그레이션만 적용)에서 수정 전에는 같은 오류로 실패하는 것을 재현한 뒤, 수정 후 빌드 · 12개 경로 응답 · 콘솔 에러 0건을 확인했다.
- Vercel 미리보기 로그에서
Applying migration …(최초) →No pending migrations(이후)를 확인했다.
- "로컬에서 되는 것"과 "빈 환경에서 되는 것"은 다르다. 배포는 빈 DB에서 시작한다. 빈 상태(상품 0개 · 주문 0건)를 일부러 만들어 빌드와 화면을 확인한다.
-
배포 명령이 DB를 준비하지 않으면 마이그레이션은 직접 해야 한다. 운영용 명령(
migrate deploy)을 배포 스크립트에 넣는다. - 빌드 시점 데이터에 의존하는 코드(
generateStaticParams)는 데이터가 없을 때의 동작을 정한다.
📎 Reference
- 🎨 Colors
- ✏️ Design
- 🧩 Components
- 🔀 State
- 🏗️ Architecture
- 🗃️ Data
- ⏱️ Time
- 🤖 Figma MCP
- 🧪 Testing
- 🚀 Performance
- 🛠️ Troubleshooting