프론트엔드가 서버를 부르는 자리는 셋으로 갈릴 수 있다.
안 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)이 아니라 여기서 만든 표시용 모양만 다룹니다.
그리고 에러도 코드로 갈린다. INQUIRY_ERROR.NOT_FOUND처럼 보이는 이름들은 서버가 보낸 숫자 에러 코드에 붙인 이름이다 — 숫자를 이름으로 바꾸는 규칙은 원칙 3에서 다룬다.
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' }),
};
한 줄씩 따라가기. get·post·put·del 은 화면이 부르는 함수 이름이고, 그 안의 method: 'GET'처럼 대문자로 적은 값이 실제로 서버에 전달되는 HTTP 메서드다 — GET 은 가져오기, POST 는 새로 만들기, PUT 은 전체를 통째로 바꾸기, DELETE 는 지우기를 서버에 알린다. request(path, { ...opts, method: 'GET' })는 request 라는 공통 함수를 부르면서, 호출한 쪽이 넘긴 opts 값들에 method: 'GET'을 덧붙여 넘긴다는 뜻이다.
한 곳을 지나야만 되는 것들이 있다.
오리진 판단 정적 export 라 브라우저는 상대경로, 서버 렌더 때는 API_ORIGIN 을 붙인다
세션 쿠키 credentials: 'same-origin'
본문 없는 응답 204 에 json() 을 부르면 터진다
파싱 실패 프록시가 HTML 에러 페이지를 주면 원문을 그대로 싣는다
한 줄씩 보면, 오리진 판단은 요청을 보내는 주소와 서버 주소가 같은 범위인지에 따라 상대경로를 쓸지 전체 주소(API_ORIGIN)를 붙일지를 가르는 것이다. 이 프로젝트는 정적 export 방식이라 브라우저에서는 상대경로로 충분하지만, 서버에서 미리 페이지를 만들 때는 상대경로가 통하지 않아 전체 주소를 붙여야 한다. 세션 쿠키는 로그인 상태를 유지하려고 브라우저에 저장해 두는 쿠키이고, credentials: 'same-origin'은 같은 오리진으로 보내는 요청에는 그 쿠키를 자동으로 함께 싣는다는 뜻이다. 204는 “성공했지만 돌려줄 내용이 없다”는 상태 코드인데, 본문이 빈 응답에 json()으로 파싱을 시도하면 예외가 난다. 마지막으로 프록시가 중간에 끼어 있으면 서버가 보낸 JSON 대신 프록시가 만든 HTML 에러 페이지가 올 수 있다.
마지막이 특히 중요하다. 서버가 정상일 때 보내는 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 로 경계를 넘을 때마다 변환하게 한 것과 같은 이유다 — 한쪽이 바뀌어도 다른 쪽이 안 따라 바뀐다.
서버가 contentExcerpt 를 summary 로 바꾸면 도메인 파일 한 곳만 고친다. 화면이 서버 이름을 직접 쓰면 그 이름이 나온 곳을 전부 찾아야 한다.
대가는 파일이 하나 더 생기는 것이다. 호출이 하나뿐인 도메인에도 파일을 만든다. 그래도 만드는 이유는 두 번째 호출이 생길 때가 아니라 서버가 이름을 바꿀 때 값을 하기 때문이다 — 그건 호출 개수와 무관하게 일어난다.
원칙 3. 실패는 ApiError 로 오고, 코드로 갈린다
request() 가 실패를 ApiError 하나로 만든다.
if (!res.ok) throw new ApiError(res.status, parsed);
한 줄씩 따라가기. res는 fetch가 돌려주는 응답 객체다. res.ok는 HTTP 상태 코드가 200번대(성공)이면 true, 아니면 false이고, res.status는 그 상태 코드 숫자 자체(404·500처럼)를 담고 있다. 그래서 이 줄은 “응답이 실패였다면 상태 코드와 파싱한 본문을 담아 ApiError를 던진다”는 뜻이다.
그리고 도메인 파일이 그 코드에 이름을 붙인다.
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자리 숫자를 화면에 쓰지 않는다. - 아는 코드만 분기한다. 나머지는 한 갈래로 — 안 그러면 백엔드가 코드를 더할 때 화면이 따라 는다.
- 자동으로 다시 부르지 않는다. 언제 풀리는지 모르면 간격을 정할 근거가 없고, 짐작으로 두드리면 더 오래 막힌다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기