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

예상한 실패    서버가 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.jsglobal-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.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.

같은 층의 layout.js 가 터지면 그 error.js 는 못 잡는다. 그래서 둘이 필요하다.

app/
  global-error.js       루트 layout 이 터질 때. 마지막 그물
  error.js              그 아래 전부
  reviews/
    error.js            후기 갈래에서만 다르게 보여주고 싶을 때
  • 최소한 app/error.jsapp/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.jsapp/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` 를 쓰지 않는다.** 서버가 데이터를 기다릴 일이 없어 뜰 자리가 없다. 이건 「아직 안 정함」이 아니라 **「정해서 안 쓴다」**이고, 안 적어두면 만들어보고 안 뜨는 이유를 찾느라 시간을 쓴다. **로딩은 클라이언트 상태와 스켈레톤으로 한다.** 서버가 기다리는 구조로 바뀌면 이 규칙을 다시 본다.

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

댓글남기기