실패에는 두 종류가 있고, 지금 한쪽만 다루고 있다.

예상한 실패    서버가 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.js wraps loading.js, not-found.js, page.js, and nested layout.js files in a React error boundary. It does not wrap the layout.js or template.js above 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 라 서버가 기다릴 일이 없다 — 「아직 안 정함」이 아니라 「정해서 안 쓴다」다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기