실패에는 두 종류가 있고, 지금 한쪽만 다루고 있다.
예상한 실패 서버가 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/ 아무것도 없다
error.js 도 global-error.js 도 없다. 그래서 화면을 그리다 예외가 터지면 잡을 것이 없다.
{review.author.name} // author 가 없으면 여기서 터진다
try/catch 는 부를 때를 감싼다. 위 코드는 그릴 때 터지므로 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.
즉 같은 층의 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.
그리고 global-error.js 는 루트 레이아웃을 통째로 대체하므로 자기 <html> · <body> 와 스타일·폰트를 직접 들어야 한다. metadata 는 못 내보내고 React 의 <title> 로 대신한다.
대가는 global-error.js 가 전역 스타일을 다시 불러야 하는 것이다. 루트 레이아웃이 하던 일을 안 물려받으므로, 거기서 토큰 CSS 를 빠뜨리면 에러 화면만 색이 없다.
원칙 3. loading.js 는 쓰지 않는다
loading.js 는 서버가 데이터를 기다리는 동안 보여주는 것이다. 이 사이트에는 그 기다림이 없다.
정적 export 페이지는 빌드 때 이미 HTML 이 된다
데이터 브라우저가 나중에 부른다
서버가 기다릴 일이 없으니 loading.js 가 뜰 자리가 없다.
그래서 「안 쓴다」를 규칙으로 적는다. 안 적어두면 다음 사람이 만들어보고 「왜 안 뜨지」로 시간을 쓴다. 없는 규칙이 아니라 없다는 규칙이 필요한 자리다.
로딩은 지금처럼 클라이언트 상태와 스켈레톤으로 한다.
{isLoading
? Array.from({ length: 4 }).map((_, i) => <InquiryCardSkeleton key={i} />)
: items.map(...)}
서버가 데이터를 기다리는 구조로 바뀌면 이 규칙을 다시 본다. 그때는 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 라 서버가 기다릴 일이 없다 — 「아직 안 정함」이 아니라 「정해서 안 쓴다」다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/failure-ui-standard/SKILL.md · .agents/skills/failure-ui-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: failure-ui-standard
description: 화면에서 실패와 로딩을 어떻게 보여주는지 정한다. 예상한 실패와 예상 못 한 예외를 가르는 기준, error.js 와 global-error.js 를 두는 자리, loading.js 를 안 쓰는 이유, 로딩 표시 방법을 담는다. 에러 화면을 만들 때, API 실패를 처리할 때, 로딩 상태를 그릴 때 적용한다. Next.js App Router 기준이다.
---
# 실패 화면 표준
**실패는 두 종류이고 장치가 다르다.**
| | 무엇으로 | 화면이 하는 일 |
|---|---|---|
| **예상한 실패** — 404 · 429 · 권한 없음 | `try/catch` + 화면 상태 | 그 상황에 맞는 문구 |
| **예상 못 한 예외** — 렌더 중 터짐 | `error.js` (Error Boundary) | 「문제가 생겼습니다」 + 다시 시도 |
**가르는 기준은 「코드가 이 실패를 미리 알고 있었나」다.**
- 서버가 `404002001` 을 준다 → 알고 있었다. 도메인 파일에 이름이 있고 화면이 분기한다.
- `author` 가 없어서 터진다 → 몰랐다. 알았으면 애초에 방어했을 것이다.
**`try/catch` 는 부를 때를 감싸지 그릴 때를 못 감싼다.** 렌더 중 예외는 Error Boundary 만 잡는다.
**모든 예외를 `try/catch` 로 막지 않는다.** 방어 코드가 화면마다 늘어나는 것보다 **버그가 조용히 삼켜지는 것**이 더 나쁘다.
## `error.js` 를 두는 자리
```
app/
global-error.js 루트 layout 이 터질 때. 마지막 그물
error.js 그 아래 전부
reviews/
error.js 그 갈래에서만 다르게 보여줄 것이 있을 때만
```
- **최소한 `app/error.js` 와 `app/global-error.js` 둘은 둔다.** `error.js` 는 **같은 층의 `layout.js` 를 안 감싸서**, 루트 레이아웃 예외는 `global-error.js` 만 잡는다.
- **갈래별 `error.js` 는 다르게 보여줄 것이 있을 때만 만든다.** 없으면 위쪽 것이 잡는다.
- **둘 다 클라이언트 컴포넌트여야 한다.** `'use client'` 를 붙인다.
- **`global-error.js` 는 루트 레이아웃을 통째로 대체한다.** 자기 `<html>` · `<body>` 와 전역 스타일·폰트를 직접 들어야 하고, `metadata` 는 못 내보내므로 React 의 `<title>` 을 쓴다. **토큰 CSS 를 빠뜨리면 에러 화면만 색이 없다.**
## `loading.js` 는 쓰지 않는다
- **정적 export 에서는 서버가 데이터를 기다릴 일이 없다.** 페이지는 빌드 때 HTML 이 되고 데이터는 브라우저가 나중에 부른다. **`loading.js` 가 뜰 자리가 없다.**
- 이건 「아직 안 정함」이 아니라 **「정해서 안 쓴다」**다. 안 적어두면 만들어보고 안 뜨는 이유를 찾느라 시간을 쓴다.
- **서버가 데이터를 기다리는 구조로 바뀌면 이 규칙을 다시 본다.**
**로딩은 클라이언트 상태와 스켈레톤으로 한다.**
```jsx
{isLoading
? Array.from({ length: 4 }).map((_, i) => <InquiryCardSkeleton key={i} />)
: items.map(...)}
```
## 하지 않는 것
- **예상 못 한 예외를 `try/catch` 로 막으려 하지 않는다.** 버그가 조용히 삼켜진다.
- **`error.js` 하나만 두고 `global-error.js` 를 빠뜨리지 않는다.**
- **`global-error.js` 에서 `<html>`·`<body>`·전역 스타일을 빠뜨리지 않는다.**
- **`error.js` 에 `'use client'` 를 빠뜨리지 않는다.**
- **정적 export 에서 `loading.js` 를 만들지 않는다.**
GitHub Copilot — .github/instructions/failure-ui-standard.instructions.md
---
description: 화면에서 실패와 로딩을 어떻게 보여주는가
applyTo: "**"
---
# 실패 화면
- **실패는 두 종류이고 장치가 다르다.** **예상한 실패**(404·429·권한 없음)는 `try/catch` + 화면 상태로, **예상 못 한 예외**(렌더 중 터짐)는 `error.js`(Error Boundary)로 잡는다. 가르는 기준은 **「코드가 이 실패를 미리 알고 있었나」**다.
- **`try/catch` 는 부를 때를 감싸지 그릴 때를 못 감싼다.** `{review.author.name}` 처럼 그리다 터지는 것은 Error Boundary 만 잡는다.
- **모든 예외를 `try/catch` 로 막지 않는다.** 방어 코드가 화면마다 늘어나는 것보다 **버그가 조용히 삼켜지는 것**이 더 나쁘다 — 화면은 멀쩡한데 값이 안 뜨고 아무도 왜인지 모른다.
- **최소한 `app/error.js` 와 `app/global-error.js` 둘을 둔다.** `error.js` 는 **같은 층의 `layout.js` 를 안 감싸서** 루트 레이아웃 예외는 `global-error.js` 만 잡는다. 갈래별 `error.js` 는 **거기서 다르게 보여줄 것이 있을 때만** 만든다.
- **Error Boundary 는 클라이언트 컴포넌트여야 한다.** `'use client'` 를 붙인다.
- **`global-error.js` 는 루트 레이아웃을 통째로 대체한다.** 자기 `<html>`·`<body>`와 전역 스타일·폰트를 직접 들어야 하고 `metadata` 는 못 내보낸다(React 의 `<title>` 을 쓴다). **토큰 CSS 를 빠뜨리면 에러 화면만 색이 없다.**
- **정적 export 에서는 `loading.js` 를 쓰지 않는다.** 서버가 데이터를 기다릴 일이 없어 뜰 자리가 없다. 이건 「아직 안 정함」이 아니라 **「정해서 안 쓴다」**이고, 안 적어두면 만들어보고 안 뜨는 이유를 찾느라 시간을 쓴다. **로딩은 클라이언트 상태와 스켈레톤으로 한다.** 서버가 기다리는 구조로 바뀌면 이 규칙을 다시 본다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기