실패에는 두 종류가 있고, 지금 한쪽만 다루고 있다.
예상한 실패 서버가 404 · 429 로 답한다. 일어날 줄 알고 있던 일
예상 못 한 예외 화면을 그리다 터진다. 일어날 줄 몰랐던 일
404와 429는 HTTP 상태 코드다 — 404는 「찾는 게 없다」, 429는 「요청을 너무 자주 보냈다」는 뜻으로, 서버가 요청을 거부할 때 응답에 실어 보내는 세 자리 번호다.
앞쪽은 화면이 상태로 잘 다루고 있다. 뒤쪽은 아무도 안 잡는다.
먼저: 예상한 실패는 이미 잘 되어 있다
이건 원칙이 아니라 이 표준이 손댈 곳과 안 댈 곳을 가르는 실측이다.
if (code === INQUIRY_ERROR.NOT_FOUND) { ... }
if (code === INQUIRY_ERROR.ACCESS_DENIED) { ... }
setLoadError(errorCodeOf(error) === INQUIRY_ERROR.SEARCH_RATE_LIMITED ? 'rate-limited' : 'failed');
프론트엔드 API 계층 표준이 이 부분을 이미 정했다 — ApiError 로 오고, 도메인 파일이 코드에 이름을 붙이고, 화면은 이름으로 갈린다.
로딩도 마찬가지다.
InquiryCardSkeleton · ReviewCardSkeleton 회색 자리표시
isFetchingNextPage 더 불러오는 중
이 글은 그 둘을 바꾸지 않는다. 비어 있는 한 곳만 채우고, 채우지 않기로 한 것 하나를 못 박는다.
먼저: error.js 가 두 앱 다 하나도 없다
client/src/app/ not-found.js 하나
admin/src/app/ 아무것도 없다
app/은 Next.js의 App Router가 라우팅 기준으로 삼는 폴더다. 그 안에 정해진 이름으로 파일을 두면 Next.js가 자동으로 역할을 맡기는데, not-found.js도 같은 규칙으로 「이 폴더에서 주소를 못 찾았을 때 보여줄 화면」을 맡는 이름이다.
error.js 도 global-error.js 도 없다. 그래서 화면을 그리다 예외가 터지면 잡을 것이 없다.
{review.author.name} // author 가 없으면 여기서 터진다
한 줄씩 보면, JSX 안에서 { }는 자바스크립트 값을 화면에 끼워 넣는 문법이다. {review.author.name}은 review.author.name 값을 그대로 화면에 찍으라는 뜻인데, author가 없으면(undefined) .name을 읽는 순간 자바스크립트가 바로 예외를 던진다.
try/catch 는 부를 때를 감싼다 — 내가 직접 함수()처럼 불러서 실행하는 코드를 감쌀 때만 그 함수 안에서 난 예외를 잡는다. 그런데 위 코드가 실행되는 시점은 내가 직접 부르는 때가 아니라, React가 화면을 그리려고 이 컴포넌트를 자기 내부에서 불러 그리는 때다. 내 코드가 그 호출을 감싸고 있지 않으므로 try/catch 가 못 잡는다. 그리고 잡히지 않은 렌더 예외는 그 위 트리를 통째로 걷어낸다 — 화면이 하얘진다. 여기서 트리는 화면을 그리는 컴포넌트들이 부모-자식으로 이어진 구조를 말한다.
원칙 1. 예상한 실패는 상태로, 예상 못 한 예외는 error.js 로 잡는다
둘을 한 장치로 다루려 하지 않는다.
| 무엇으로 | 화면이 하는 일 | |
|---|---|---|
| 예상한 실패 — 404 · 429 · 권한 없음 | try/catch + 화면 상태 |
그 상황에 맞는 문구를 보여준다 |
| 예상 못 한 예외 — 렌더 중 터짐 | error.js (Error Boundary) |
「문제가 생겼습니다」 + 다시 시도 |
가르는 기준은 「이 실패를 코드가 미리 알고 있었나」다.
- 서버가
404002001을 준다 → 알고 있었다. 도메인 파일에 이름이 있고 화면이 분기한다. author가 없어서 터진다 → 몰랐다. 알았으면 애초에 방어했을 것이다.
모든 예외를 try/catch 로 막으려 하지 않는다. 그러면 방어 코드가 화면마다 늘어나고, 정작 버그는 조용히 삼켜져 아무도 모르게 된다. 예상 못 한 것은 잡아서 화면을 지키되, 그것이 버그라는 사실이 드러나야 한다.
원칙 2. error.js 는 라우트 갈래마다 두고, 루트는 global-error.js 가 받는다
error.js 는 자기보다 안쪽만 감싼다.
error.jswrapsloading.js,not-found.js,page.js, and nestedlayout.jsfiles in a React error boundary. It does not wrap thelayout.jsortemplate.jsabove it in the same segment.
풀어 쓰면, error.js는 자신보다 안쪽에 있는 loading.js·not-found.js·page.js·중첩된 layout.js(그 폴더와 하위 폴더 전체를 공통으로 감싸는 틀을 그리는 파일이다)를 Error Boundary로 감싸지만, 같은 층(같은 segment)에 있는 layout.js나 template.js는 감싸지 않는다는 뜻이다.
즉 같은 층의 layout.js 가 터지면 그 error.js 는 못 잡는다. 그래서 둘이 필요하다.
app/
global-error.js 루트 layout 이 터질 때. 마지막 그물
error.js 그 아래 전부
reviews/
error.js 후기 갈래에서만 다르게 보여주고 싶을 때
- 최소한
app/error.js와app/global-error.js둘은 둔다. 하나만 두면 루트 레이아웃 예외가 안 잡힌다. - 갈래마다 두는 것은 「거기서 다르게 보여줄 것이 있을 때」만 한다. 없으면 위쪽 것이 잡는다.
둘 다 클라이언트 컴포넌트여야 한다.
Error boundaries must be Client Components.
풀어 쓰면, Error Boundary는 클라이언트 컴포넌트여야 한다는 뜻이다. 서버에서만 도는 서버 컴포넌트로는 에러를 화면에서 바로 잡아 다시 보여주는 동작을 만들 수 없다.
그리고 global-error.js 는 루트 레이아웃을 통째로 대체하므로 자기 <html> · <body> 와 스타일·폰트를 직접 들어야 한다. metadata 는 못 내보내고 React 의 <title> 로 대신한다.
대가는 global-error.js 가 전역 스타일을 다시 불러야 하는 것이다. 루트 레이아웃이 하던 일을 안 물려받으므로, 거기서 토큰 CSS 를 빠뜨리면 에러 화면만 색이 없다.
원칙 3. loading.js 는 쓰지 않는다
loading.js 는 서버가 데이터를 기다리는 동안 보여주는 것이다. 이 사이트에는 그 기다림이 없다.
정적 export 페이지는 빌드 때 이미 HTML 이 된다
데이터 브라우저가 나중에 부른다
이 사이트는 정적 export 방식이라 모든 페이지가 빌드 시점에 이미 HTML로 만들어져 있고, 화면에 보일 데이터만 브라우저가 나중에 따로 가져온다.
서버가 기다릴 일이 없으니 loading.js 가 뜰 자리가 없다.
그래서 「안 쓴다」를 규칙으로 적는다. 안 적어두면 다음 사람이 만들어보고 「왜 안 뜨지」로 시간을 쓴다. 없는 규칙이 아니라 없다는 규칙이 필요한 자리다.
로딩은 지금처럼 클라이언트 상태와 스켈레톤으로 한다.
{isLoading
? Array.from({ length: 4 }).map((_, i) => <InquiryCardSkeleton key={i} />)
: items.map(...)}
한 줄씩 보면, Array.from({ length: 4 })는 길이가 4인 빈 배열을 만드는 자바스크립트 문법이다 — { length: 4 }라는 객체를 “길이 4짜리 배열처럼” 취급해 [undefined, undefined, undefined, undefined]를 만든다. 그 뒤의 .map((_, i) => <InquiryCardSkeleton key={i} />)가 그 네 자리를 각각 스켈레톤 컴포넌트로 바꾼다. 결과는 로딩 중에 보여줄 자리표시 카드 4개다.
서버가 데이터를 기다리는 구조로 바뀌면 이 규칙을 다시 본다. 그때는 loading.js 가 할 일이 생긴다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 무엇으로 가르나 | 코드가 이 실패를 미리 알고 있었나 | 알았으면 상태, 몰랐으면 error.js |
| 404 · 429 | 화면 상태 | 도메인 파일이 이름을 붙이고 화면이 분기한다 |
| 렌더 중 터짐 | error.js |
try/catch 는 그릴 때를 못 감싼다 |
모든 예외를 try/catch 로 |
안 한다 | 방어 코드가 늘고 버그가 조용히 삼켜진다 |
error.js 를 몇 개 두나 |
루트에 하나 + global-error |
갈래별은 다르게 보여줄 것이 있을 때만 |
루트 layout.js 가 터지면 |
global-error.js |
같은 층의 error.js 는 못 잡는다 |
loading.js |
안 쓴다 | 정적 export 라 서버가 기다릴 일이 없다 |
| 로딩 표시 | 스켈레톤 + 클라이언트 상태 | 지금 방식이 맞다 |
이 표준을 정하기까지
API 계층 표준을 쓰고 나서 바로 옆이 비어 있는 게 보였다. 실패를 어떻게 받는지는 정했는데 화면에서 어떻게 보여줄지는 error.js · loading.js 규약을 쓸지조차 안 정해져 있었다.
세어 보니 두 앱 다 error.js 가 없었다. not-found.js 하나뿐이다. 그런데 catch 는 여섯 곳 넘게 있었다. 즉 실패를 안 다루는 게 아니라 한 종류만 다루고 있었다.
그래서 실패가 두 종류라는 것이 이 표준의 축이 됐다. 서버가 404002001 을 주는 것과 author 가 없어서 터지는 것은 다른 일이다. 앞쪽은 코드가 미리 알고 있던 것이고 뒤쪽은 몰랐던 것이다. try/catch 는 부를 때를 감싸지 그릴 때를 못 감싼다.
「모든 예외를 try/catch 로 막는다」는 안을 버린 이유가 중요하다. 그렇게 하면 방어 코드가 화면마다 늘어나는 것보다 버그가 조용히 삼켜지는 것이 더 나쁘다. 화면은 멀쩡해 보이는데 값이 안 뜨고, 아무도 왜인지 모른다.
loading.js 를 「안 쓴다」로 적을지가 마지막 판단이었다. 안 적으면 규칙이 없는 것이고, 적으면 규칙이 하나 는다. 적기로 한 이유는 이게 「아직 안 정함」이 아니라 「정해서 안 쓰는 것」이기 때문이다 — 정적 export 에서는 서버가 기다릴 일이 없어 뜰 자리가 없다. 안 적어두면 다음 사람이 만들어보고 안 뜨는 이유를 찾느라 시간을 쓴다.
Next 16 문서에서 확인한 것이 둘 있다. error.js 는 같은 층의 layout.js 를 안 감싼다는 것과, global-error.js 는 루트 레이아웃을 대체하므로 <html> · <body> 와 스타일을 직접 들어야 한다는 것이다. 후자를 모르고 만들면 에러 화면만 색이 없는 상태가 된다.
정리
- 실패는 두 종류이고 장치가 다르다. 코드가 미리 알고 있었으면 상태로, 몰랐으면
error.js로 잡는다. try/catch는 부를 때를 감싸지 그릴 때를 못 감싼다. 렌더 중 예외는 Error Boundary 만 잡는다.- 모든 예외를
try/catch로 막지 않는다. 버그가 조용히 삼켜지는 것이 더 나쁘다. app/error.js와app/global-error.js둘을 둔다. 같은 층의layout.js는 그 층의error.js가 못 잡는다.global-error.js는<html>·<body>·스타일을 직접 든다. 빠뜨리면 에러 화면만 색이 없다.loading.js는 쓰지 않는다. 정적 export 라 서버가 기다릴 일이 없다 — 「아직 안 정함」이 아니라 「정해서 안 쓴다」다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기