프론트엔드가 서버를 부르는 자리는 셋으로 갈릴 수 있다.

안 A   화면이 fetch 를 직접 부른다
안 B   전송만 한 곳으로 모으고 나머지는 화면이 한다
안 C   전송 · 도메인 · 화면 세 겹으로 나눈다

이 프로젝트는 이미 안 C 로 서 있다. 이 글은 그것을 새로 정하는 것이 아니라 꺼내 적는 것이다.


먼저: 실측해보니 새로 정할 것이 거의 없었다

이건 원칙이 아니라 이 표준의 성격이다.

lib/api.js       69줄   request() 하나 · ApiError(status, body) · get/post/put/del
lib/reviews.js  163줄   도메인 호출 + 표시용 변환 + 에러 코드 상수
화면                    표시용 모양만 다룬다

lib/reviews.js 주석이 그 경계를 그대로 적어뒀다.

화면 컴포넌트는 서버 응답의 필드 이름(reviewNo, contentExcerpt, caregiverName)이 아니라 여기서 만든 표시용 모양만 다룹니다.

그리고 에러도 코드로 갈린다.

if (code === INQUIRY_ERROR.NOT_FOUND) { ... }
if (code === INQUIRY_ERROR.ACCESS_DENIED) { ... }
setLoadError(errorCodeOf(error) === INQUIRY_ERROR.SEARCH_RATE_LIMITED ? 'rate-limited' : 'failed');

errorCodeOf() 헬퍼까지 있고, 왜 그렇게 처리하는지도 적혀 있다 — “검색 호출량 한도는 서버가 남은 시간을 알려주지 않는다. 자동으로 다시 부르지 않고 사용자가 누를 때만 재시도한다.”

말로 꺼내진 적만 없었다. 그래서 새 앱을 만들거나 새 도메인을 붙일 때 같은 구조가 될 보장이 없다.


원칙 1. 모든 요청은 lib/api.js 하나를 지난다

화면이 fetch 를 직접 부르지 않는다.

// lib/api.js — 이 파일만 fetch 를 부른다
const api = {
  get:  (path, opts)       => request(path, { ...opts, method: 'GET' }),
  post: (path, body, opts) => request(path, { ...opts, method: 'POST', body }),
  put:  (path, body, opts) => request(path, { ...opts, method: 'PUT', body }),
  del:  (path, opts)       => request(path, { ...opts, method: 'DELETE' }),
};

한 곳을 지나야만 되는 것들이 있다.

오리진 판단      정적 export 라 브라우저는 상대경로, 서버 렌더 때는 API_ORIGIN 을 붙인다
세션 쿠키        credentials: 'same-origin'
본문 없는 응답    204 에 json() 을 부르면 터진다
파싱 실패        프록시가 HTML 에러 페이지를 주면 원문을 그대로 싣는다

마지막이 특히 중요하다. JSON.parse 가 던지게 두면 진짜 원인(상태 코드)이 가려진다. 그래서 원문을 ApiError 에 실어 올린다. 화면마다 fetch 를 부르면 이 처리를 화면 수만큼 다시 만들어야 하고, 하나라도 빠뜨리면 그 화면에서만 엉뚱한 에러가 보인다.


원칙 2. 도메인 파일이 서버 모양을 표시용 모양으로 바꾼다

lib/api.js 위에 도메인마다 파일 하나가 선다.

lib/api.js         전송
lib/reviews.js     후기 호출 + 표시용 변환 + 에러 코드
lib/inquiries.js   문의

화면은 서버 응답의 필드 이름을 모른다. reviewNo · contentExcerpt 가 아니라 도메인 파일이 만든 모양만 받는다.

이건 백엔드가 이미 쓰는 규칙과 같은 모양이다. 계층별 DTO 네이밍 표준Request → Command → Domain → Entity경계를 넘을 때마다 변환하게 한 것과 같은 이유다 — 한쪽이 바뀌어도 다른 쪽이 안 따라 바뀐다.

서버가 contentExcerptsummary 로 바꾸면 도메인 파일 한 곳만 고친다. 화면이 서버 이름을 직접 쓰면 그 이름이 나온 곳을 전부 찾아야 한다.

대가는 파일이 하나 더 생기는 것이다. 호출이 하나뿐인 도메인에도 파일을 만든다. 그래도 만드는 이유는 두 번째 호출이 생길 때가 아니라 서버가 이름을 바꿀 때 값을 하기 때문이다 — 그건 호출 개수와 무관하게 일어난다.


원칙 3. 실패는 ApiError 로 오고, 코드로 갈린다

request() 가 실패를 ApiError 하나로 만든다.

if (!res.ok) throw new ApiError(res.status, parsed);

그리고 도메인 파일이 그 코드에 이름을 붙인다.

export const REVIEW_ERROR = {
  NOT_FOUND: 404002001,
};

숫자를 화면에 직접 쓰지 않는다. 404002001 이 화면 코드에 박히면 그 숫자가 무엇인지 아는 사람이 없고, 백엔드가 코드를 바꿀 때 찾을 수가 없다.

이 9자리는 백엔드 ErrorCode 계약 표준이 정한 체계다. 프론트는 그 숫자를 도메인 파일에서 한 번만 받아 이름으로 바꾼다.

화면은 이름으로 갈린다.

if (code === INQUIRY_ERROR.NOT_FOUND)  { ... }
if (code === INQUIRY_ERROR.ACCESS_DENIED) { ... }

모르는 코드는 갈라내지 않는다. 아는 코드만 분기하고 나머지는 「실패했습니다」 한 갈래로 보낸다. 모든 코드를 화면이 알아야 하면 백엔드가 코드를 더할 때마다 화면이 따라 늘어난다.


원칙 4. 자동으로 다시 부르지 않는다

실패했을 때 자동 재시도를 넣지 않는다.

lib/inquiries.js 쪽 처리에 그 이유가 적혀 있다.

검색 호출량 한도(429001002)는 서버가 남은 시간을 알려주지 않는다. 자동으로 다시 부르지 않고 사용자가 누를 때만 재시도한다.

서버가 언제 풀리는지 안 알려주면 재시도 간격을 정할 근거가 없다. 짐작으로 넣으면 한도에 걸린 채로 계속 두드려 더 오래 막힌다.

대신 사용자에게 다시 누를 것을 준다. 화면이 실패 상태를 들고 버튼을 보여주면, 언제 다시 부를지를 사람이 정한다.

서버가 남은 시간을 주는 경우는 예외로 열어둔다. Retry-After 같은 값이 오면 그것을 따를 수 있다. 다만 지금은 그런 응답이 없어서 규칙을 만들지 않았다 — 없는 것을 그려두지 않는다.


판단 기준 정리

질문 결론
화면에서 fetch 를 불러도 되나 안 된다 오리진·쿠키·204·파싱 실패 처리를 화면 수만큼 다시 만들게 된다
도메인 파일을 꼭 만드나 호출이 하나여도 만든다 서버가 이름을 바꿀 때 고칠 곳이 하나여야 한다
화면이 서버 필드 이름을 쓰나 안 쓴다 도메인 파일이 표시용 모양으로 바꾼다
에러 코드 숫자를 화면에 쓰나 안 쓴다 도메인 파일이 이름을 붙인다
모든 에러 코드를 분기하나 아는 것만 나머지는 한 갈래로. 안 그러면 백엔드가 늘 때 화면이 따라 는다
실패하면 자동으로 다시 부르나 안 부른다 언제 풀리는지 모르면 간격을 정할 근거가 없다

이 표준을 정하기까지

「API 는 이미 잘 되어 있으니 적어두기만 하면 된다」고 해놓고 안 적었다. 프론트엔드 표준 일곱을 쓰는 동안 계속 미뤄졌고, 마지막에 안 다룬 영역을 훑다가 다시 나왔다.

열어보니 정말로 새로 정할 것이 거의 없었다. lib/api.js 가 전송을 맡고, 도메인 파일이 변환과 에러 코드를 맡고, 화면은 표시용 모양만 다룬다. 세 겹이 이미 서 있었다.

그리고 그 구조가 백엔드와 같은 모양이었다. 계층별 DTO 네이밍 표준이 Request → Command → Domain → Entity 로 경계마다 변환하게 한 것과 같다 — 한쪽이 바뀌어도 다른 쪽이 안 따라 바뀐다. 프론트에서는 서버 응답과 화면 사이에 도메인 파일이 그 자리를 맡고 있었다.

에러 쪽도 마찬가지였다. REVIEW_ERROR.NOT_FOUND = 404002001 처럼 백엔드 ErrorCode 9자리를 도메인 파일에서 이름으로 바꾸고, 화면은 이름으로 갈린다. errorCodeOf() 헬퍼까지 있었다.

새로 판단한 것은 하나뿐이다 — 자동 재시도를 안 넣는 것. 코드에는 「검색 호출량 한도는 서버가 남은 시간을 안 알려주므로 사용자가 누를 때만 재시도한다」가 한 자리에 적혀 있었는데, 그게 그 자리에서만 통하는 처리인지 전체 규칙인지가 안 정해져 있었다. 규칙으로 올렸다 — 언제 풀리는지 모르면 간격을 정할 근거가 없고, 짐작으로 두드리면 더 오래 막힌다.

Retry-After 를 받으면 어떻게 할지는 안 정했다. 지금 그런 응답이 없기 때문이다. 없는 것을 그려두면 다음에 읽을 때 정한 것으로 착각한다.


정리

  • 모든 요청은 lib/api.js 하나를 지난다. 오리진·쿠키·204·파싱 실패 처리가 거기 모여 있다.
  • 도메인마다 파일 하나를 두고, 화면은 서버 필드 이름을 모른다. 서버가 이름을 바꾸면 고칠 곳이 하나다.
  • 실패는 ApiError 하나로 오고, 도메인 파일이 코드에 이름을 붙인다. 9자리 숫자를 화면에 쓰지 않는다.
  • 아는 코드만 분기한다. 나머지는 한 갈래로 — 안 그러면 백엔드가 코드를 더할 때 화면이 따라 는다.
  • 자동으로 다시 부르지 않는다. 언제 풀리는지 모르면 간격을 정할 근거가 없고, 짐작으로 두드리면 더 오래 막힌다.

AI 코드 어시스턴트에 바로 적용하기

Claude Code · Codex — .claude/skills/frontend-api-layer-standard/SKILL.md · .agents/skills/frontend-api-layer-standard/SKILL.md

두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.

---
name: frontend-api-layer-standard
description: 프론트엔드가 서버를 부르는 계층과 실패를 다루는 방법을 정한다. 전송을 모으는 파일, 도메인 파일이 하는 변환, 에러 코드를 이름으로 바꾸는 자리, 자동 재시도를 안 하는 이유를 담는다. 서버를 호출할 때, 새 도메인을 붙일 때, API 실패를 화면에서 처리할 때 적용한다.
---

# 프론트엔드 API 계층 표준

**세 겹으로 나눈다. 화면은 서버 필드 이름을 모른다.**

```
lib/api.js         전송 — fetch 를 부르는 유일한 곳
lib/<도메인>.js     도메인 호출 + 표시용 변환 + 에러 코드 이름
화면               표시용 모양만 다룬다
```

## 전송은 한 곳으로 모은다

```javascript
const api = {
  get:  (path, opts)       => request(path, { ...opts, method: 'GET' }),
  post: (path, body, opts) => request(path, { ...opts, method: 'POST', body }),
  put:  (path, body, opts) => request(path, { ...opts, method: 'PUT', body }),
  del:  (path, opts)       => request(path, { ...opts, method: 'DELETE' }),
};
```

- **화면에서 `fetch` 를 직접 부르지 않는다.** 한 곳을 지나야 하는 처리가 있다 — 오리진 판단(서버 렌더 때만 절대경로), 세션 쿠키, 본문 없는 응답(204 에 `json()` 을 부르면 터진다), 파싱 실패.
- **파싱 실패는 던지지 말고 원문을 실어 올린다.** 프록시가 HTML 에러 페이지를 주는 경우가 있는데, 거기서 던지면 **진짜 원인(상태 코드)이 가려진다.**

## 도메인 파일이 모양을 바꾼다

- **도메인마다 파일 하나.** 호출이 하나뿐이어도 만든다.
- **화면은 서버 응답의 필드 이름을 모른다.** 도메인 파일이 표시용 모양으로 바꾼다.
- 서버가 필드 이름을 바꾸면 **고칠 곳이 도메인 파일 하나다.** 화면이 서버 이름을 직접 쓰면 그 이름이 나온 곳을 전부 찾아야 한다.
- 백엔드의 계층별 DTO 네이밍 표준이 경계마다 변환하는 것과 **같은 이유다.**

## 실패는 코드로 갈린다

```javascript
// 도메인 파일이 이름을 붙인다
export const REVIEW_ERROR = { NOT_FOUND: 404002001 };

// 화면은 이름으로 갈린다
if (code === REVIEW_ERROR.NOT_FOUND) { ... }
```

- **9자리 숫자를 화면에 직접 쓰지 않는다.** 그 숫자가 무엇인지 아는 사람이 없고, 백엔드가 바꿀 때 찾을 수가 없다.
- **아는 코드만 분기한다.** 나머지는 「실패했습니다」 한 갈래로 보낸다 — 모든 코드를 화면이 알아야 하면 **백엔드가 코드를 더할 때마다 화면이 따라 는다.**
- 검증 오류처럼 본문이 필요한 것은 `error instanceof ApiError` 로 확인하고 `error.body` 에서 꺼낸다.

## 자동으로 다시 부르지 않는다

- **실패했다고 자동 재시도를 넣지 않는다.** 서버가 언제 풀리는지 안 알려주면 **재시도 간격을 정할 근거가 없고**, 짐작으로 두드리면 한도에 걸린 채로 더 오래 막힌다.
- **대신 사용자에게 다시 누를 것을 준다.** 화면이 실패 상태를 들고 버튼을 보여주면 언제 다시 부를지를 사람이 정한다.
- 서버가 `Retry-After` 같은 값을 주면 그것을 따를 수 있다. **지금은 그런 응답이 없어 규칙을 만들지 않았다.**

## 하지 않는 것

- **화면에서 `fetch` 를 직접 부르지 않는다.**
- **화면이 서버 응답의 필드 이름을 쓰지 않는다.**
- **에러 코드 숫자를 화면에 박지 않는다.**
- **모르는 에러 코드까지 화면에서 갈라내지 않는다.**
- **실패에 자동 재시도를 붙이지 않는다.**

GitHub Copilot — .github/instructions/frontend-api-layer-standard.instructions.md

---
description: 프론트엔드가 서버를 부르는 계층과 실패를 다루는 방법
applyTo: "**"
---

# 프론트엔드 API 계층

- **세 겹으로 나눈다**`lib/api.js`(전송, `fetch` 를 부르는 유일한 곳) · `lib/<도메인>.js`(도메인 호출 + 표시용 변환 + 에러 코드 이름) · 화면(표시용 모양만). **화면은 서버 응답의 필드 이름을 모른다.**
- **화면에서 `fetch` 를 직접 부르지 않는다.** 한 곳을 지나야 하는 처리가 있다 — 오리진 판단(서버 렌더 때만 절대경로), 세션 쿠키, 본문 없는 응답(204 에 `json()` 을 부르면 터진다), 파싱 실패. **파싱 실패는 던지지 말고 원문을 실어 올린다** — 프록시가 HTML 에러 페이지를 주는 경우가 있고 거기서 던지면 진짜 원인인 상태 코드가 가려진다.
- **도메인마다 파일 하나를 만든다. 호출이 하나뿐이어도 만든다.** 서버가 필드 이름을 바꾸면 고칠 곳이 그 파일 하나다 — 화면이 서버 이름을 직접 쓰면 그 이름이 나온 곳을 전부 찾아야 한다. 백엔드의 계층별 DTO 네이밍 표준이 경계마다 변환하는 것과 같은 이유다.
- **실패는 `ApiError` 하나로 오고 도메인 파일이 코드에 이름을 붙인다.** `REVIEW_ERROR.NOT_FOUND = 404002001` 처럼 두고 화면은 이름으로 갈린다 — **9자리 숫자를 화면에 박지 않는다.** 그 숫자가 무엇인지 아는 사람이 없고 백엔드가 바꿀 때 찾을 수가 없다.
- **아는 코드만 분기한다.** 나머지는 한 갈래로 보낸다 — 모든 코드를 화면이 알아야 하면 **백엔드가 코드를 더할 때마다 화면이 따라 는다.** 검증 오류처럼 본문이 필요한 것은 `error instanceof ApiError` 로 확인하고 `error.body` 에서 꺼낸다.
- **실패에 자동 재시도를 붙이지 않는다.** 서버가 언제 풀리는지 안 알려주면 간격을 정할 근거가 없고, 짐작으로 두드리면 한도에 걸린 채로 더 오래 막힌다. **대신 사용자가 다시 누를 것을 준다.** 서버가 `Retry-After` 를 주면 그것을 따를 수 있다.

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

댓글남기기