프론트엔드(브라우저에서 도는 화면 쪽 프로그램) 프로젝트가 커지면서 가장 먼저 복잡해지는 부분이 API 호출 관리다. 처음엔 간단하게 컴포넌트(화면 한 조각을 그리는 함수)에서 직접 fetch나 axios(fetch와 같은 역할을 하는 또 다른 HTTP 요청 라이브러리)를 호출하다가, 어느 순간 같은 API를 여러 페이지에서 중복 호출하고, API 엔드포인트가 변경되면 프로젝트 전체를 뒤져야 하는 상황에 직면한다.
이 글에서는 체계적이고 유지보수 가능한 API 관리 전략을 소개한다. 단순히 코드를 정리하는 수준이 넘어, 팀 전체의 생산성을 높이고 백엔드와의 협업을 원활하게 만드는 구조를 제시한다.
문제 인식: 왜 API 관리가 어려운가?
1. 중복과 분산의 악순환
동일한 API를 여러 페이지에서 호출하는 경우, API 엔드포인트가 변경되면 모든 페이지를 찾아서 수정해야 한다.
// UserListPage.jsx
fetch('/api/v1/users/123')
.then(res => res.json())
.then(data => setUser(data));
// UserProfilePage.jsx
fetch('/api/v1/users/123') // 동일한 API
.then(res => res.json())
.then(data => setProfile(data));
// AdminDashboard.jsx
fetch('/api/v1/users/123') // 또 동일한 API
.then(res => res.json())
.then(data => setAdminData(data));
파일 이름 끝의 .jsx는 자바스크립트 안에 화면 모양을 섞어 쓰는 JSX 문법을 담은 파일이라는 표시다(뒤에 나오는 .tsx는 같은 것을 TypeScript로 쓴 파일이다).
한 줄씩 따라가기. fetch('/api/v1/users/123')는 그 주소로 요청을 보내고 Promise를 돌려준다. .then(res => res.json())의 res => res.json()은 화살표 함수다 — 응답(res)을 받아서 .json()으로 응답 본문(JSON 형식의 글자)을 자바스크립트 객체로 바꾼다. 그 결과가 다음 .then(data => ...)으로 넘어가서 data로 받아진다 — 이렇게 .then()을 이어 붙이는 것이 Promise 체이닝이다. 마지막의 setUser(data), setProfile(data)는 받은 값을 그 화면이 기억하는 상태에 넣는 함수다 — 어디서 오는지는 뒤의 「핵심 원칙 4」에서 useState와 함께 본다. 지금은 「받은 값을 화면에 넣는다」로만 읽으면 된다.
API 경로가 /api/v1/users/{id}에서 /api/v2/members/{id}로 변경되면? (서버가 API를 새 버전으로 올리면서 주소의 v1을 v2로, 자원 이름을 users에서 members로 바꾼 상황을 가정한 예시다.) 프로젝트 전체를 검색해서 일일이 수정해야 한다.
2. 가독성과 유지보수성의 트레이드오프
API를 변수나 함수로 추출하면 중복은 해결되지만, 새로운 문제가 발생한다. 중복을 없애는 대신 읽기가 어려워지는 트레이드오프다.
// api/constants.js
export const USER_API = '/api/v1/users/';
// 컴포넌트에서 사용
fetch(USER_API + userId)
한 줄씩 따라가기. export는 이 상수를 다른 파일에서 가져다 쓸 수 있게 내놓는 표시다. 컴포넌트는 그 상수 뒤에 userId를 문자열로 이어 붙여 주소를 만든다.
이 코드를 보는 개발자는 다음 질문을 하게 된다:
USER_API가 정확히 어떤 API인가?- GET인가 POST인가?(HTTP 메서드 — 가져오기인가 새로 만들기인가)
- 어떤 파라미터가 필요한가?
- 응답 구조는 어떻게 생겼나?
결국 api/constants.js 파일을 열어봐야 하고, 함수로 추출했다면 함수 정의를 찾아가야 한다.
3. 일관성 없는 에러 처리
각 컴포넌트마다 에러 처리 방식이 다르면, 사용자 경험도 일관되지 않고 디버깅도 어려워진다.
// PageA.jsx - 토스트 표시
catch (error) {
showToast('에러가 발생했습니다');
}
// PageB.jsx - 콘솔 로그만
catch (error) {
console.error(error);
}
// PageC.jsx - 에러 페이지로 이동
catch (error) {
navigate('/error');
}
showToast(...)는 화면 구석에 잠깐 뜨는 토스트 메시지를 띄우는 함수이고, navigate('/error')는 주소를 /error로 바꿔 에러 화면으로 옮겨 가는 함수다(React Router가 주는 것이다). console.error(error)는 브라우저 개발자 도구 콘솔에 에러를 찍기만 해서 사용자에게는 아무것도 보이지 않는다. 셋 다 「실패했다」를 다루는 방법인데 페이지마다 제각각이다.
해결 전략: 체계적인 API 관리 아키텍처
핵심 원칙 1: 백엔드 API 1개 = 프론트엔드 함수 1개
Single Source of Truth 원칙(같은 정보는 한 곳에만 둔다)을 적용한다. 백엔드에서 제공하는 API 하나당 프론트엔드에서도 정확히 하나의 함수나 클래스로 정의한다.
// ❌ 잘못된 예: 같은 API를 여러 곳에서 정의
// userList.ts
export const fetchUser = (id) => axios.get(`/api/v1/users/${id}`);
// userProfile.ts
export const getUser = (id) => axios.get(`/api/v1/users/${id}`); // 중복!
// ✅ 올바른 예: 한 곳에서만 정의
// api/user.ts
export const getUser = (id: number) =>
apiClient.get<User>(`/users/${id}`);
// 모든 컴포넌트에서 동일하게 사용
import { getUser } from '@/api/user';
한 줄씩 따라가기. (id) => axios.get(...)도 앞서 본 것과 같은 화살표 함수다. axios.get(url)은 fetch와 같은 역할을 하는 axios 라이브러리로 GET 요청을 보내는 코드다. 주소를 감싼 역따옴표(`)는 템플릿 리터럴로, ${id} 자리에 id 값이 들어간다. (id: number)의 : number는 타입 표기로 “id는 숫자여야 한다”는 뜻이다. apiClient는 이 글의 「핵심 원칙 3」에서 만드는, 기본 주소 /api/v1을 미리 넣어 둔 axios 객체다 — 그래서 여기서는 /users/${id}만 적는다. apiClient.get<User>(...)의 <User>는 이 요청의 응답이 User 타입이라고 미리 알려주는 자리로, 제네릭 문법이다(User의 모양은 아래 「3. 타입 정의」에서 정의한다). 마지막 줄 import { getUser } from '@/api/user'는 import로 다른 파일이 내놓은 getUser를 그 이름 그대로 가져오는 문장이다. @/는 프로젝트 설정에서 정해 두는 경로 별칭이고, 이 글에서는 src/를 가리킨다고 가정한다.
이렇게 한 곳에 모으면 앞의 네 질문에 함수 하나가 답한다. 이름(getUser)과 그 안의 apiClient.get이 “사용자 하나를 GET으로 가져온다”를, id: number가 필요한 파라미터를, <User>가 응답 모양을 알려 준다. 정의를 한 번만 찾아가면 되고, 그 정의는 한 곳에만 있다.
핵심 원칙 2: 백엔드 Controller 구조와 1:1 매핑
프론트엔드의 API 파일 구조를 백엔드의 Controller 구조와 동일하게 유지한다.
이점:
- API 엔드포인트를 찾기 쉬움
- 백엔드 개발자와 소통할 때 동일한 용어 사용
- 백엔드 변경사항을 프론트엔드에 빠르게 반영 가능
핵심 원칙 3: 라이브러리 의존성 격리
컴포넌트가 특정 라이브러리에 직접 기대지(의존하지) 않게 떼어 놓는다는 뜻이다. HTTP 클라이언트 라이브러리(axios, fetch 등)를 한 겹 감싸는 추상화 레이어(컴포넌트와 라이브러리 사이에 끼운 파일 하나)로 감싸서, 나중에 라이브러리를 교체해도 컴포넌트 코드는 변경하지 않도록 한다.
// api/config.ts - 라이브러리 의존성 격리
import axios from 'axios';
export const apiClient = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 인터셉터로 공통 로직 처리
apiClient.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
response => response,
error => {
// 실패한 요청을 여기서 자동으로 다시 부르지 않는다.
// 인터셉터는 실패를 그대로 올려보내고, 다시 부를지는 화면이 정한다.
// (401 토큰 만료만은 예외로 한 번 갱신한다 — 아래 「실무 적용 › 2. API 설정 파일」)
if (error.response?.status === 401) {
clearSession(); // 토큰을 버리고
redirectToLogin(); // 로그인 화면으로 보낸다
}
throw error;
}
);
한 줄씩 따라가기. axios.create({ baseURL, timeout, headers })는 기본 주소(baseURL)와 응답을 기다리는 최대 시간(timeout, 밀리초 단위라 10000은 10초), 모든 요청에 공통으로 붙일 헤더(본문 앞에 붙는 부가 정보)를 미리 설정해 둔 axios 인스턴스를 만든다. 여기서 붙이는 헤더 Content-Type: application/json은 “보내는 본문이 JSON이다”라는 뜻이다. apiClient.interceptors.request.use(config => { ... })는 인터셉터를 등록하는 코드로, 요청이 나가기 전에 가로채 — config는 axios가 넘겨주는 이번 요청의 설정(주소 · 헤더 등)이고, 고친 config를 return 해야 그 설정으로 요청이 나간다 — localStorage에 저장해 둔 토큰을 꺼내 Authorization: Bearer ${token} 헤더에 실어 보낸다 — 이게 Bearer 토큰 방식이다. apiClient.interceptors.response.use(...)는 응답을 가로채는 인터셉터로, 함수 두 개를 받는다. 첫 번째 response => response는 상태 코드가 2xx(성공)일 때 돈다 — 성공 응답은 손대지 않고 그대로 넘긴다는 뜻이다. 두 번째 error => { ... }는 2xx 밖일 때 돈다. error.response?.status === 401은 응답의 HTTP 상태 코드가 401(인증 실패)인지 확인하는 코드다 — ?.는 error.response가 없을 때(네트워크가 끊겨 서버 응답 자체를 못 받은 경우) 에러를 내지 않고 undefined를 돌려주는 옵셔널 체이닝이고, ===는 타입까지 같은지 보는 일치 연산자다. clearSession()과 redirectToLogin()은 이 글에서 만들지 않은 함수로, 주석대로 “토큰을 지운다”, “로그인 화면으로 보낸다”를 맡는 자리다. 마지막 throw error는 실패를 삼키지 않고 이 요청을 부른 컴포넌트까지 그대로 올려보낸다.
자동 재시도를 넣지 않는 이유는 프론트엔드 API 계층 표준 원칙 4에 정리되어 있다. 서버는 짧은 시간에 요청을 너무 많이 받으면 더 받지 않는 호출량 한도를 둘 수 있다. 이때 서버가 Retry-After 헤더처럼 언제 풀리는지 알려주지 않으면 재시도 간격을 정할 근거가 없고, 짐작으로 넣으면 한도에 걸린 채로 계속 두드려 더 오래 막힌다. 다시 부를지는 사용자가 버튼을 눌러 정하게 한다.
401(토큰 만료)만은 예외로 한 번 다시 보낸다. 위 근거는 「언제 풀리는지 모른다」였다. 토큰이 만료되어 받은 401은 다르다 — 원인(액세스 토큰이 낡았다)도, 푸는 법(리프레시 토큰으로 새 토큰을 받는다)도 이미 안다. 그래서 새 토큰을 받아 원래 요청을 딱 한 번 다시 보낸다. 갱신을 아예 빼면 액세스 토큰이 만료될 때마다 사용자가 하던 일을 잃고 로그인 화면으로 튕긴다. 대신 두 가지를 지킨다. 같은 요청은 한 번만 다시 보내고(_retry 표시), 갱신 요청은 이 인터셉터가 걸린 apiClient가 아니라 맨 axios로 보낸다 — 갱신 요청까지 apiClient로 보내면, 갱신이 401을 받는 순간 다시 이 인터셉터가 돌아 또 갱신을 시도하는 고리가 생긴다. 코드는 아래 「실무 적용 › 2. API 설정 파일」에 있다. 위의 짧은 코드는 원칙만 보이려고 갱신을 뺐다.
참고: Axios의 인터셉터는 요청/응답 전후에 공통 로직을 처리할 수 있는 강력한 기능이다. 자세한 내용은 Axios 공식 문서 - Interceptors를 참조한다.
나중에 axios를 fetch로 교체하고 싶다면? api/config.ts 파일만 수정하면 된다. 컴포넌트는 axios를 직접 부르지 않고 API 함수(이 글 뒤쪽에서 만드는 userApi.getUser() 같은 것)만 부르기 때문에, 그 함수를 그대로 사용할 수 있다. 다만 api/config.ts 안에서는 apiClient.get · apiClient.post 같은 모양과 인터셉터가 하던 일(토큰 붙이기, 401 처리)을 fetch로 똑같이 다시 만들어야 한다. 그 구현은 이 글에서 다루지 않았다.
핵심 원칙 4: 컴포넌트에서 비즈니스 로직 처리
API 호출은 공통이지만, 호출 후의 비즈니스 로직(받은 데이터로 그 화면이 할 일)은 컴포넌트마다 다르다. 따라서 에러 처리나 로딩 상태는 각 컴포넌트에서 개별적으로 처리한다.
앞의 「3. 일관성 없는 에러 처리」와 부딪혀 보이지만 나누는 선이 다르다. 모든 화면에 똑같아야 하는 실패(401이면 토큰을 한 번 갱신하고, 그래도 안 되면 로그인 화면으로)는 인터셉터 한 곳이 맡고, 화면마다 달라야 하는 실패(프로필은 토스트, 관리자는 에러 화면)만 컴포넌트가 맡는다. 문제였던 것은 같아야 할 처리까지 페이지마다 제각각이던 것이다.
// 프로필 페이지
const ProfilePage = () => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const loadUser = async () => {
try {
const { data } = await userApi.getUser(userId);
setUser(data.data);
// 프로필 페이지만의 로직: 폼에 데이터 채우기
fillFormData(data.data);
} catch (error) {
// 프로필 페이지만의 에러 처리
showToast('사용자 정보를 불러올 수 없습니다');
}
};
loadUser();
}, [userId]);
};
// 관리자 페이지
const AdminPage = () => {
const [users, setUsers] = useState<User[]>([]);
const addUser = async (userId: number) => {
try {
const { data } = await userApi.getUser(userId);
// 관리자 페이지만의 로직: 목록에 추가
setUsers(prev => [...prev, data.data]);
} catch (error) {
// 관리자 페이지만의 에러 처리
navigate('/admin/error');
}
};
};
한 줄씩 따라가기. useState로 적은 const [user, setUser] = useState<User | null>(null)은 user라는 상태를 null로 시작해서 만든다 — User | null은 유니온 타입으로 “User이거나 null”이라는 뜻이다. useEffect로 적은 useEffect(() => { ... }, [userId])는 화면이 그려진 뒤 userId가 바뀔 때마다 안의 코드를 다시 실행한다. const loadUser = async () => { try { ... } catch (error) { ... } }는 비동기 처리 함수를 선언하는 코드다 — await는 userApi.getUser(userId)가 끝날 때까지 기다렸다가 결과({ data }, 구조 분해로 바로 꺼낸 값)를 받으라는 뜻이고, 그 사이 실패하면 catch 블록으로 넘어간다.
몇 가지를 더 짚는다. const ProfilePage = () => { ... }처럼 함수 하나가 곧 화면 한 조각, 즉 컴포넌트다. 원래는 마지막에 화면 모양을 return 하는데, 여기서는 API를 부르는 부분만 보이려고 생략했다(돌려주는 모양은 아래 「5. 컴포넌트에서 사용」에 있다). fillFormData(입력 칸에 사용자 정보를 채우는 함수) · navigate · showToast도 이 조각에서는 선언을 생략했다. addUser는 관리자가 버튼을 눌러 사용자를 목록에 더할 때 부른다고 놓은 함수다. userId도 이 조각에서는 어디서 오는지 생략했다 — 실제로는 부모가 넘겨준 props나 주소에서 꺼낸 값이다. axios가 돌려주는 객체의 data는 서버가 보낸 본문이고, 이 글의 서버는 본문을 { data: 사용자, message, timestamp } 모양(아래 ApiResponse)으로 감싸 보낸다. 그래서 사용자 자체는 data.data다. useEffect 안에서 loadUser를 따로 만들고 바로 부르는 것은, useEffect에 넘기는 함수는 정리용 함수 말고는 돌려주지 않게 되어 있는데 async 함수는 늘 Promise를 돌려주기 때문이다 — React 공식 문서의 예제도 이 모양이다. setUsers(prev => [...prev, data.data])의 prev는 React가 넘겨주는 지금 목록이고, [...prev, data.data]는 펼침 문법으로 기존 항목을 모두 펼쳐 넣고 끝에 새 사용자를 붙인 새 배열이다.
실무 적용: 구체적인 구조
백엔드 구조 (Spring Boot)
// UserController.java
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
// 사용자 조회 로직
return ResponseEntity.ok(userService.getUser(id));
}
@PostMapping
public ResponseEntity<UserResponse> createUser(
@RequestBody CreateUserRequest request
) {
// 사용자 생성 로직
return ResponseEntity.ok(userService.createUser(request));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@RequestBody UpdateUserRequest request
) {
// 사용자 수정 로직
return ResponseEntity.ok(userService.updateUser(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
// 사용자 삭제 로직
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
// ProductController.java
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@GetMapping
public ResponseEntity<List<ProductResponse>> getProducts(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page
) {
// 상품 목록 조회 로직
return ResponseEntity.ok(productService.getProducts(category, page));
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
// 상품 상세 조회 로직
return ResponseEntity.ok(productService.getProduct(id));
}
}
한 줄씩 따라가기. @RestController가 붙은 클래스는 요청을 제일 먼저 받는 창구다. @RequestMapping("/api/v1/users")는 이 클래스의 모든 메서드 앞에 붙는 공통 주소를 정하고, @GetMapping("/{id}")는 그 뒤에 /{id}를 붙여 GET 요청만 받는다. @PathVariable Long id는 주소에 박힌 값을 꺼내 받는 표시다. 메서드가 돌려주는 ResponseEntity는 응답 본문과 HTTP 상태 번호를 함께 담는다 — ResponseEntity.ok(값)은 상태 200(성공)에 그 값을 본문으로, ResponseEntity.noContent().build()는 상태 204(성공, 본문 없음)를 만든다. @PostMapping · @PutMapping · @DeleteMapping은 @GetMapping과 같은 자리에서 각각 POST(만들기) · PUT(통째로 바꾸기) · DELETE(지우기) 메서드의 요청만 받는다. @RequestBody는 요청 본문의 JSON을 CreateUserRequest 같은 자바 객체로 바꿔 받는 표시다. @RequestParam은 /products?category=book&page=1처럼 주소 물음표 뒤의 값을 받는다 — required = false는 “없어도 된다”, defaultValue = "0"은 “없으면 0으로 친다”는 뜻이다. userService · productService는 실제 일을 처리하는 Service 객체로, 이 예시에서는 선언을 생략했다.
@로 시작하는 표시는 자바의 어노테이션으로, Spring이 이 표시를 읽고 할 일을 정한다. ResponseEntity<UserResponse>의 UserResponse는 사용자 정보를 담아 돌려주는 DTO 클래스로 이 예시에서는 생략했고, ResponseEntity<Void>의 Void는 “본문이 없다”는 뜻이다.
이 백엔드는 Spring Boot로 만든 서버다. 프론트엔드 쪽에서 볼 것은 클래스 하나(UserController)가 주소 묶음 하나(/api/v1/users)를 맡는다는 점이다 — 이 단위를 그대로 프론트엔드 파일 하나로 옮긴다.
프론트엔드 구조 (TypeScript + Axios)
1. 프로젝트 폴더 구조
src/
├── api/
│ ├── config.ts # Axios 설정 및 인터셉터
│ ├── types.ts # 공통 타입 정의
│ ├── user.ts # UserController 대응
│ ├── product.ts # ProductController 대응
│ └── order.ts # OrderController 대응
├── generated/ # OpenAPI 자동 생성 파일
│ └── api-types.ts # 백엔드 스키마 기반 타입
├── components/
│ ├── UserProfile.tsx
│ └── ProductList.tsx
└── pages/
├── UserListPage.tsx
└── ProductDetailPage.tsx
2. API 설정 파일
// api/config.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 요청 인터셉터: 인증 토큰 추가
apiClient.interceptors.request.use(
config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
// 응답 인터셉터: 401(토큰 만료)이면 한 번만 갱신해 다시 보낸다. 다른 실패는 그대로 올려보낸다
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 401 에러 시 토큰 갱신 시도 (같은 요청은 한 번만)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
// apiClient 가 아니라 맨 axios 로 보낸다 — 이 인터셉터를 다시 타지 않게
const { data } = await axios.post('/api/v1/auth/refresh', {
refreshToken
});
localStorage.setItem('accessToken', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// 토큰 갱신 실패 시 로그인 페이지로
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
한 줄씩 따라가기. baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1'의 import.meta.env는 Vite(프론트엔드 개발 도구)가 빌드(소스를 브라우저에 올릴 파일로 만드는 일)할 때 넣어 주는 환경변수를 읽는 문법이다. 요청 인터셉터는 「핵심 원칙 3」과 같고, 토큰을 저장한 이름만 'token' 대신 'accessToken'으로 적었다 — 로그인할 때 서버에서 받은 토큰을 이 이름으로 localStorage에 저장해 두었다고 놓은 것이고, 그 저장 단계는 이 글에서 다루지 않는다. ||는 자바와 달리 boolean이 아닌 값에도 쓸 수 있어서, 왼쪽 값이 비어 있으면 오른쪽 '/api/v1'을 쓰라는 뜻이 된다. 아래에 나오는 Promise.reject(error)는 “이 에러로 실패한 Promise”를 만든다. 인터셉터 함수 안에서 이것을 return 하는 것과 「핵심 원칙 3」처럼 throw error 하는 것은 결과가 같다 — 둘 다 그 에러로 실패를 올려보낸다. 요청 인터셉터의 두 번째 함수 error => Promise.reject(error)는 요청을 보내기 전에 생긴 에러를 삼키지 않고 그대로 실패로 넘긴다. 응답 인터셉터는 상태 코드가 2xx 밖일 때 async error => { ... }가 돈다. 순서대로 따라간다.
error.config는 실패한 그 요청의 설정(주소 · 헤더 · 본문)이다. 다시 보내려고originalRequest에 잡아 둔다!originalRequest._retry는 “이 요청을 아직 한 번도 다시 보내지 않았다”는 뜻이다._retry는 axios가 주는 값이 아니라, 우리가 설정 객체에 직접 붙인 표시다. 다시 보내기 직전에true로 바꾸므로, 다시 보낸 요청이 또 401을 받으면 이if에 들어오지 않고 맨 아래return Promise.reject(error)로 실패를 올려보낸다 — 무한히 돌지 않는 이유가 이것이다localStorage에 저장해 둔 리프레시 토큰을 꺼내/api/v1/auth/refresh로 새 액세스 토큰을 달라고 한다. 여기서apiClient가 아니라 맨axios.post를 쓴다. 인터셉터는apiClient에만 걸려 있어서, 갱신 요청이 401을 받아도 이 인터셉터가 다시 돌지 않는다. 갱신 응답은ApiResponse로 감싸지 않고{ accessToken }을 바로 준다고 놓았다- 받은 새 토큰을 저장하고, 원래 요청의
Authorization헤더를 새 토큰으로 바꿔apiClient(originalRequest)로 다시 보낸다. 그 결과가 처음 요청을 부른 컴포넌트에게 그대로 돌아간다 — 컴포넌트는 중간에 401이 있었는지 모른다 - 갱신마저 실패하면(리프레시 토큰도 만료됐거나 없으면)
localStorage.clear()로 토큰을 전부 지우고, 브라우저의 지금 주소를 담은window.location.href에'/login'을 넣어 로그인 화면으로 보낸다 - 401이 아닌 실패는 마지막
return Promise.reject(error)로 그대로 호출한 쪽(컴포넌트)에 올려보내서, 컴포넌트의catch가 받게 한다 — 「핵심 원칙 3」의 「자동으로 다시 부르지 않는다」는 여기서 지켜진다
이 코드를 두고 한 번 흔들렸다. 「핵심 원칙 3」 본문이 “401도 자동으로 다시 부르지 않는다”고 적고 있어서, 주니어 검토 중에 이 갱신 코드를 지우고 “401이면 바로 로그인 화면으로”로 바꿨다가 되돌렸다(2026-10-20). 어긋난 쪽은 코드가 아니라 원칙 3의 설명이었다. 자동 재시도를 막는 근거는 「언제 풀리는지 모른다」인데, 토큰 만료는 푸는 법을 안다. 그리고 원칙 3이 걱정한 「무한히 돈다」는 _retry 표시와 맨 axios 두 가지로 이미 막혀 있었다. 갱신을 빼는 쪽은 고리가 생길 일은 없지만, 토큰이 만료될 때마다 사용자를 로그인 화면으로 보내는 값을 치른다. 그래서 원칙 3의 설명을 「429 같은 실패는 다시 부르지 않고, 401 토큰 만료만 한 번 갱신한다」로 고쳤다.
3. 타입 정의
// api/types.ts
export interface User {
id: number;
name: string;
email: string;
role: 'USER' | 'ADMIN';
createdAt: string;
updatedAt: string;
}
export interface CreateUserRequest {
name: string;
email: string;
password: string;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
}
export interface Product {
id: number;
name: string;
price: number;
category: string;
stock: number;
description: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
timestamp: string;
}
한 줄씩 따라가기. 여기의 interface는 자바의 interface와 다르다. 구현할 메서드 목록이 아니라 “이 객체는 이런 이름과 타입의 필드를 가진다”는 데이터 모양을 적는다 — 자바로 치면 필드만 있는 DTO 클래스의 설계도에 가깝다. name?: string의 ?는 선택 속성으로, 수정 요청에서 name을 보내도 되고 안 보내도 된다는 뜻이다. role: 'USER' | 'ADMIN'도 앞서 본 것과 같은 유니온 타입이다 — role 값이 문자열 'USER'나 'ADMIN' 둘 중 하나여야 한다는 뜻이다. export interface ApiResponse<T>의 <T>도 마찬가지로 제네릭 자리다 — ApiResponse<User>라고 쓰면 그 자리가 User로 채워진다.
4. API 함수 정의 (도메인별)
// api/user.ts
import { apiClient } from './config';
import { User, CreateUserRequest, UpdateUserRequest, ApiResponse } from './types';
export const userApi = {
/**
* 사용자 조회
* GET /api/v1/users/:id
*/
getUser: (id: number) =>
apiClient.get<ApiResponse<User>>(`/users/${id}`),
/**
* 사용자 생성
* POST /api/v1/users
*/
createUser: (data: CreateUserRequest) =>
apiClient.post<ApiResponse<User>>('/users', data),
/**
* 사용자 수정
* PUT /api/v1/users/:id
*/
updateUser: (id: number, data: UpdateUserRequest) =>
apiClient.put<ApiResponse<User>>(`/users/${id}`, data),
/**
* 사용자 삭제
* DELETE /api/v1/users/:id
*/
deleteUser: (id: number) =>
apiClient.delete<void>(`/users/${id}`)
};
// api/product.ts
import { apiClient } from './config';
import { Product, ApiResponse } from './types';
export const productApi = {
/**
* 상품 목록 조회
* GET /api/v1/products
*/
getProducts: (params?: { category?: string; page?: number }) =>
apiClient.get<ApiResponse<Product[]>>('/products', { params }),
/**
* 상품 상세 조회
* GET /api/v1/products/:id
*/
getProduct: (id: number) =>
apiClient.get<ApiResponse<Product>>(`/products/${id}`)
};
한 줄씩 따라가기. export const userApi = { getUser: ..., createUser: ... }는 클래스를 만들지 않고 중괄호 안에 이름과 함수를 짝지어 그 자리에서 객체 하나를 만드는 객체 리터럴이다. 그래서 컴포넌트는 userApi.getUser(7)처럼 메서드를 부르듯 쓴다. 주석의 GET /api/v1/users/:id에서 :id는 백엔드 코드의 {id}와 같은 뜻으로, “여기에 id 값이 들어간다”는 자리 표시다. getProducts의 params?: { category?: string; page?: number }는 “없어도 되는 조건 객체”이고, 이것을 { params }로 axios에 넘기면 axios가 주소 뒤에 ?category=book&page=1 같은 쿼리 파라미터로 붙여 보낸다 — 백엔드의 @RequestParam이 바로 이 값을 받는다. { params }는 { params: params }를 줄여 쓴 것이다. apiClient.post('/users', data)와 apiClient.put(..., data)의 두 번째 인자 data는 요청 본문으로 실려 가고, 백엔드의 @RequestBody가 이것을 받는다. 응답 타입이 「핵심 원칙 1」의 get<User>와 달리 get<ApiResponse<User>>인 것은, 이 서버가 본문을 ApiResponse로 감싸 보내기 때문이다(「3. 타입 정의」). 원칙 1의 코드는 모양만 보이려고 감싸지 않은 채로 적었다.
5. 컴포넌트에서 사용
// components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { userApi } from '@/api/user';
import { User } from '@/api/types';
interface UserProfileProps {
userId: number;
}
export const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const { data } = await userApi.getUser(userId);
setUser(data.data);
} catch (err) {
setError('사용자 정보를 불러올 수 없습니다');
console.error('Failed to fetch user:', err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>로딩 중...</div>;
if (error) return <div className="error">{error}</div>;
if (!user) return null;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
<span className="role">{user.role}</span>
</div>
);
};
// pages/ProductDetailPage.tsx
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { productApi } from '@/api/product';
import { Product } from '@/api/types';
export const ProductDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
const loadProduct = async () => {
if (!id) return;
try {
const { data } = await productApi.getProduct(Number(id));
setProduct(data.data);
} catch (error) {
alert('상품을 찾을 수 없습니다');
navigate('/products');
}
};
loadProduct();
}, [id, navigate]);
if (!product) return <div>상품 정보를 불러오는 중...</div>;
return (
<div className="product-detail">
<h1>{product.name}</h1>
<p className="price">₩{product.price.toLocaleString()}</p>
<p className="category">{product.category}</p>
<p className="description">{product.description}</p>
<p className="stock">재고: {product.stock}개</p>
</div>
);
};
한 줄씩 따라가기. 두 파일 다 React(화면을 컴포넌트 단위로 만드는 라이브러리)로 쓴 컴포넌트다. 폴더는 화면 조각을 담는 components/와 주소 하나에 해당하는 화면 전체를 담는 pages/로 나눴다.
-
import React, { useEffect, useState } from 'react'에서 중괄호 밖의React는 그 파일이 대표로 내놓은 값을, 중괄호 안은 이름을 붙여 따로 내놓은 값을 가져온다 UserProfile의React.FC<UserProfileProps>는 “이 함수는 React 컴포넌트이고, 받는 props는UserProfileProps모양이다”라는 타입 표시다.({ userId })는 넘겨받은 props 객체에서userId만 구조 분해로 꺼낸 것이다. 부모 화면이<UserProfile userId={7} />처럼 값을 넘긴다setUser(data.data)에서data가 두 번인 이유는 앞의 「핵심 원칙 4」에서 본 대로다. 앞의data는 axios가 담아 준 서버 본문(ApiResponse<User>)이고, 뒤의.data가 그 안의 사용자다finally는 자바와 같다 — 성공하든 실패하든 마지막에 늘 실행되어 로딩 상태를 끈다if (loading) return ...부터는 상태에 따라 다른 화면을 돌려준다.return null은 아무것도 그리지 않는다는 뜻이다. 함수가 돌려주는<div className="user-profile">...</div>는 JSX로 쓴 화면 모양이다.className은 HTML의class속성과 같은 일을 하고,{user.name}처럼 중괄호 안에는 자바스크립트 값을 넣어 화면에 보여 준다ProductDetailPage의useParams와useNavigate는 React Router(react-router-dom패키지)가 주는 함수다.useParams<{ id: string }>()는 주소/products/:id의:id자리 값을 문자열로 꺼내 주고, 그래서Number(id)로 숫자로 바꿔 넘긴다.useNavigate()가 돌려준navigate('/products')는 상품 목록 화면으로 옮겨 간다.alert(...)는 브라우저가 메시지 창을 띄우고 사용자가 닫을 때까지 기다리게 한다.product.price.toLocaleString()은 숫자를 사용자 언어에 맞춘 글자로 바꾼다 —3500이"3,500"처럼 세 자리마다 쉼표가 들어간다useEffect의 마지막[id, navigate]에navigate도 넣은 것은, React가 효과 안에서 쓰는 컴포넌트 안의 값을 전부 이 목록에 적으라고 정해 두었기 때문이다
구조 다이어그램
Axios 설정] TYPES[types.ts
타입 정의] USER_API[user.ts
UserController 대응] PRODUCT_API[product.ts
ProductController 대응] ORDER_API[order.ts
OrderController 대응] end subgraph Components["컴포넌트 레이어"] PROFILE[UserProfile.tsx] LIST[ProductList.tsx] DETAIL[OrderDetail.tsx] end end UC -.->|1:1 매핑| USER_API PC -.->|1:1 매핑| PRODUCT_API OC -.->|1:1 매핑| ORDER_API CONFIG --> USER_API CONFIG --> PRODUCT_API CONFIG --> ORDER_API TYPES --> USER_API TYPES --> PRODUCT_API TYPES --> ORDER_API USER_API --> PROFILE PRODUCT_API --> LIST ORDER_API --> DETAIL style Backend fill:#1a202c,stroke:#2d3748,color:#e2e8f0 style Frontend fill:#1a202c,stroke:#2d3748,color:#e2e8f0 style API fill:#2d3748,stroke:#4a5568,color:#e2e8f0 style Components fill:#2d3748,stroke:#4a5568,color:#e2e8f0 style UC fill:#2c5282,stroke:#3182ce,color:#bee3f8 style PC fill:#2c5282,stroke:#3182ce,color:#bee3f8 style OC fill:#2c5282,stroke:#3182ce,color:#bee3f8 style USER_API fill:#2c5282,stroke:#3182ce,color:#bee3f8 style PRODUCT_API fill:#2c5282,stroke:#3182ce,color:#bee3f8 style ORDER_API fill:#2c5282,stroke:#3182ce,color:#bee3f8
게임 체인저: OpenAPI 자동 생성
수동으로 타입을 정의하는 것도 좋지만, 백엔드 스키마에서 자동으로 TypeScript 타입을 생성하면 두 가지 이점이 있다. 여기서 「스키마」와 「API 스펙」은 같은 것을 가리킨다 — 백엔드의 각 API가 어떤 주소 · 메서드 · 요청 모양 · 응답 모양을 가지는지 OpenAPI 형식으로 적은 문서다:
- 중복 작업 제거: 백엔드 개발자가 API 스펙을 정의하면, 프론트엔드는 타입만 생성하면 됨
- 항상 동기화: 백엔드 스펙이 변경되면 타입을 재생성하여 즉시 컴파일 에러로 감지
OpenAPI Generator 설정
1. 설치
npm install --save-dev @openapitools/openapi-generator-cli
npm install --save-dev는 이 패키지를 개발용으로만 설치한다는 뜻이다 — 실제 서비스를 실행할 때는 필요 없고, 타입을 생성할 때만 쓴다.
2. 설정 파일 생성
// package.json
{
"scripts": {
"generate:api": "openapi-generator-cli generate -i http://localhost:8080/v3/api-docs -g typescript-axios -o ./src/generated --additional-properties=withSeparateModelsAndApi=true,supportsES6=true"
}
}
package.json은 프로젝트 이름과 쓰는 라이브러리 · 명령을 JSON 형식으로 적어 둔 프로젝트 설정 파일이고, 그 안의 scripts는 npm run 이름으로 실행할 수 있는 명령을 모아 둔 곳이다. generate:api로 묶어 둔 명령 안의 -i(입력 스펙 주소), -g(생성할 언어·라이브러리), -o(저장할 폴더), --additional-properties(세부 옵션)는 전부 이 CLI(터미널에 글자로 명령을 쳐서 쓰는) 도구가 정해 둔 옵션 이름이다.
입력 주소 http://localhost:8080/v3/api-docs는 내 컴퓨터(localhost)의 8080 포트(한 컴퓨터 안에서 프로그램을 가르는 번호)에 띄워 둔 백엔드 서버가 내주는 OpenAPI 문서 주소다. Spring Boot 서버에 springdoc-openapi 라이브러리를 넣으면 기본으로 이 /v3/api-docs 주소에서 문서를 JSON으로 내준다. 그래서 이 명령을 돌리려면 백엔드 서버가 먼저 떠 있어야 한다. -g typescript-axios는 “TypeScript 코드를, axios로 요청하는 모양으로 만들어라”는 뜻이다. 세부 옵션 둘의 뜻은 공식 문서에 이렇게 적혀 있다 — withSeparateModelsAndApi=true는 타입(모델)과 API 함수를 다른 폴더 · 다른 파일로 나눠 만들고, supportsES6=true는 ES6 문법에 맞는 코드를 만든다. 같은 문서에 withSeparateModelsAndApi는 apiPackage와 modelPackage 값도 함께 줘야 한다고 적혀 있는데, 위 명령에는 그 둘이 없다. 이 명령을 그대로 돌려 보지는 않았다.
참고: OpenAPI Generator는 OpenAPI 스펙에서 클라이언트 코드와 타입을 자동 생성한다. 자세한 옵션은 OpenAPI Generator 공식 문서를 참조한다.
3. 타입 생성
npm run generate:api
생성된 파일:
src/generated/
├── api.ts # API 함수들
├── models.ts # 타입 정의들
└── configuration.ts # 설정
이 파일 목록은 직접 돌려서 확인한 것이 아니다. 실제로 생기는 파일 이름과 폴더는 생성기 버전과 위 옵션에 따라 달라질 수 있다.
4. 생성된 타입 사용
// api/user.ts
import { apiClient } from './config';
import { User, CreateUserRequest, UpdateUserRequest } from '@/generated/models';
export const userApi = {
getUser: (id: number) =>
apiClient.get<User>(`/users/${id}`),
createUser: (data: CreateUserRequest) =>
apiClient.post<User>('/users', data),
updateUser: (id: number, data: UpdateUserRequest) =>
apiClient.put<User>(`/users/${id}`, data),
deleteUser: (id: number) =>
apiClient.delete<void>(`/users/${id}`)
};
한 줄씩 따라가기. 앞의 api/user.ts와 바뀐 곳은 두 군데다. 첫째, 타입을 ./types에서 손으로 쓴 것 대신 @/generated/models에서 생성된 것을 가져온다. 생성기가 API 함수(api.ts)도 만들어 주지만 이 글은 타입만 가져다 쓰고, 요청은 앞에서 인터셉터를 달아 둔 apiClient로 계속 보낸다. 둘째, 응답 타입이 ApiResponse<User>가 아니라 User다. 이 예시는 서버가 본문을 ApiResponse로 감싸지 않고 사용자를 바로 보낸다고 놓고 쓴 것이라 앞의 user.ts와 다르다. 서버가 실제로 감싸 보낸다면 앞처럼 ApiResponse<User>로 적어야 한다.
타입 동기화 자동화
백엔드 API가 변경되면 자동으로 타입을 업데이트하도록 CI/CD(코드를 올리면 빌드 · 테스트 · 배포를 자동으로 돌리는 흐름)에 통합할 수 있다.
# .github/workflows/sync-api-types.yml
name: Sync API Types
on:
schedule:
- cron: '0 0 * * *' # 매일 자정
workflow_dispatch: # 수동 실행 가능
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Generate API types
run: npm run generate:api
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changed=true" >> $GITHUB_OUTPUT
- name: Create Pull Request
if: steps.git-check.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: Update API types from backend'
title: '🔄 API 타입 자동 업데이트'
body: '백엔드 스키마 변경사항을 반영한 타입 업데이트입니다.'
branch: auto-update-api-types
한 줄씩 따라가기. 이 파일은 GitHub Actions의 설정 파일이고, 들여쓰기로 계층을 적는 YAML 형식이다. on: schedule: - cron: '0 0 * * *'는 “매일 자정에 실행하라”는 뜻이다 — GitHub Actions의 예약 실행은 기본이 UTC(세계 표준시, 한국보다 9시간 늦다) 기준이라 한국 시간으로는 오전 9시다. runs-on: ubuntu-latest는 GitHub이 빌려주는 Ubuntu(리눅스) 컴퓨터에서 돌리라는 뜻이다. workflow_dispatch는 사람이 버튼을 눌러 수동으로도 실행할 수 있게 한다. jobs 아래 steps는 위에서부터 순서대로 실행할 작업 목록이다 — uses:는 남이 만들어 둔 작업을 가져다 쓰는 것이고, run:은 직접 셸 명령(터미널에 치는 명령)을 실행하는 것이다.
actions/checkout@v3는 이 저장소의 코드를 작업할 컴퓨터로 받아 오는 작업이다.@v3는 그 작업의 3번째 큰 버전을 쓰겠다는 표시다actions/setup-node@v3와node-version: '18'은 자바스크립트를 브라우저 밖에서 돌리는 Node.js 18 버전을 준비한다. npm 명령이 이 위에서 돈다npm ci는package-lock.json에 적힌 버전 그대로 라이브러리를 설치한다.npm install과 달리 lock 파일을 고치지 않아서 매번 같은 설치가 된다run: |의|는 아래 여러 줄을 명령으로 그대로 이어 적겠다는 YAML 표기다git diff--exit-code는 변경된 파일이 있으면 실패(0이 아닌 코드)를 돌려준다. 실패하면||뒤의echo "changed=true" >> $GITHUB_OUTPUT이 돌아서GITHUB_OUTPUT에changed=true를 적는다. 이 단계에id: git-check를 붙여 두었으므로, 다음 단계의if: steps.git-check.outputs.changed == 'true'가 그 값을 읽어 “타입이 실제로 바뀌었을 때만” 다음 단계를 돌린다peter-evans/create-pull-request@v5는 바뀐 파일을auto-update-api-types브랜치에commit-message의 메시지로 커밋하고, 그 브랜치로 PR을 연다
빠진 단계가 하나 있다. npm run generate:api는 http://localhost:8080에 떠 있는 백엔드에서 스펙을 받아 오는데, 이 워크플로에는 백엔드 서버를 띄우는 단계가 없다. 그대로 돌리면 스펙을 받아 올 곳이 없다. 실제로 쓰려면 백엔드를 이 안에서 띄우는 단계를 넣거나, -i에 밖에서 접근할 수 있는 스펙 주소를 줘야 한다.
이렇게 하면 백엔드 API가 변경될 때마다 자동으로 PR이 생성되어, 프론트엔드 팀이 변경사항을 리뷰하고 머지할 수 있다.
장점 정리
OpenAPI/Swagger] -->|자동 생성| B[TypeScript 타입] B --> C[컴파일 타임
타입 체크] C --> D[런타임 에러 방지] A -->|변경 발생| E[타입 재생성] E -->|컴파일 에러| F[즉시 감지] F --> G[안전한 리팩토링] style A fill:#1a365d,stroke:#2c5282,color:#bee3f8 style B fill:#1a365d,stroke:#2c5282,color:#bee3f8 style C fill:#1a365d,stroke:#2c5282,color:#bee3f8 style D fill:#22543d,stroke:#2f855a,color:#c6f6d5 style E fill:#742a2a,stroke:#9b2c2c,color:#fed7d7 style F fill:#742a2a,stroke:#9b2c2c,color:#fed7d7 style G fill:#22543d,stroke:#2f855a,color:#c6f6d5
그림의 컴파일 타임은 코드를 실행하기 전 TypeScript가 타입을 검사하는 때이고, 런타임은 실제로 브라우저에서 돌고 있는 때다. 서버 응답 모양이 바뀌었다는 것을 실행 전에 알면, 사용자가 화면에서 에러를 만나기 전에 고칠 수 있다. 리팩토링은 동작은 그대로 두고 코드 구조만 손보는 일이다.
실전 팁과 주의사항
1. 환경별 API URL 관리
// api/config.ts
const getBaseURL = () => {
const env = import.meta.env.MODE;
switch (env) {
case 'production':
return 'https://api.example.com/v1';
case 'staging':
return 'https://staging-api.example.com/v1';
case 'development':
default:
return 'http://localhost:8080/api/v1';
}
};
export const apiClient = axios.create({
baseURL: getBaseURL(),
timeout: 10000
});
한 줄씩 따라가기. import.meta.env.MODE는 지금 어떤 환경으로 실행 중인지(development · production 등) 이름을 담고 있다. switch는 그 이름에 따라 다른 서버 주소를 고른다 — 실제 사용자가 쓰는 운영 환경(production), 운영에 올리기 전 확인하는 스테이징 서버(staging), 내 컴퓨터(development)다. case 'development': 바로 아래에 default:가 붙어 있어서, 이름이 셋 중 아무것도 아니면 내 컴퓨터 주소를 쓴다.
앞의 「2. API 설정 파일」은 주소를 VITE_API_BASE_URL 환경변수로 밖에서 넣었고, 여기서는 환경 이름마다 주소를 코드에 적어 둔다. 같은 일을 하는 두 방법이고, 이 글에서는 둘 중 무엇을 쓸지 정하지 않았다.
2. API 버전 관리
// api/config.ts
export const createApiClient = (version: 'v1' | 'v2' = 'v1') => {
return axios.create({
baseURL: `/api/${version}`,
timeout: 10000
});
};
// api/user.ts
import { createApiClient } from './config';
const apiV1 = createApiClient('v1');
const apiV2 = createApiClient('v2');
export const userApi = {
// 레거시 API
getUserV1: (id: number) => apiV1.get(`/users/${id}`),
// 새로운 API
getUserV2: (id: number) => apiV2.get(`/members/${id}`)
};
한 줄씩 따라가기. (version: 'v1' | 'v2' = 'v1')은 version에 'v1'이나 'v2'만 받고, 넘기지 않으면 'v1'을 쓰는 기본값 매개변수다. createApiClient('v1')과 createApiClient('v2')로 기본 주소가 /api/v1, /api/v2인 axios 객체를 하나씩 만든다. 그래서 글 맨 앞에서 가정한 「/api/v1/users/{id}가 /api/v2/members/{id}로 바뀐」 상황에서, 옛 주소를 쓰는 화면이 남아 있는 동안 getUserV1(레거시 API, 아직 쓰는 옛 주소)과 getUserV2(새 주소)를 같은 파일에 나란히 둘 수 있다.
3. 타입 가드 활용
// api/types.ts
export interface ApiError {
code: string;
message: string;
details?: Record<string, any>;
}
export function isApiError(error: any): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error
);
}
// 컴포넌트에서 사용
try {
const { data } = await userApi.getUser(userId);
setUser(data);
} catch (error) {
if (isApiError(error)) {
console.error(`API Error [${error.code}]: ${error.message}`);
} else {
console.error('Unknown error:', error);
}
}
한 줄씩 따라가기. details?: Record<string, any>는 “없어도 되고, 있으면 문자열 키에 아무 값이나 든 객체”라는 뜻이다 — Record는 키와 값의 타입을 정한 객체 타입이고, any는 타입 검사를 하지 않는 타입이다. export function isApiError(error: any): error is ApiError는 타입 가드 함수다 — error is ApiError 부분이 “이 함수가 true를 돌려주면 그 뒤부터 error를 ApiError 타입으로 다뤄도 된다”는 뜻이다. 함수 안은 네 가지를 차례로 본다. typeof error === 'object'는 값이 객체인지, error !== null은 null이 아닌지(자바스크립트에서는 null도 typeof가 'object'라서 따로 거른다), 'code' in error와 'message' in error는 in 연산자로 그 이름의 속성이 있는지 본다. 그래서 if (isApiError(error)) 블록 안에서는 error.code, error.message에 바로 접근할 수 있다. 여기의 userApi는 바로 앞 「4. 생성된 타입 사용」 판이라 data가 곧 사용자다.
주의할 점이 있다. axios가 던지는 에러 객체(AxiosError) 자체에도 code와 message가 있다. 그래서 위 코드처럼 error를 그대로 넘기면 isApiError가 true가 되고, 찍히는 code는 서버가 보낸 코드가 아니라 axios가 붙인 ERR_BAD_REQUEST(대개 4xx 응답) 같은 값이다. 서버가 보낸 본문은 error.response.data에 들어 있으므로, 서버가 정한 code를 보려면 그 값을 isApiError에 넘겨야 한다.
4. 주의사항
과도한 추상화는 피하기
- API 레이어는 단순하게 유지
- 비즈니스 로직을 API 레이어에 넣지 말 것
타입 생성 시점 명확히 하기
- 개발 서버(내 컴퓨터에서 화면을 띄워 보는 서버) 시작 시 자동 생성?
- PR 올릴 때 생성?
- 수동으로 필요할 때만?
- 팀과 합의된 규칙 정하기
생성된 코드는 수정하지 않기
generated/폴더는.gitignore에 추가하거나(저장소에 올리지 않고 필요할 때마다 다시 생성) — 단 이렇게 하면git diff가 이 폴더를 보지 않으므로, 위 「타입 동기화 자동화」는 바뀐 것을 못 찾아 PR을 만들지 않는다. 그 자동화를 쓰려면 아래 방법을 쓴다- Git으로 버전 관리는 하되 직접 수정 금지
- 수정이 필요하면 생성된 함수를 안에서 대신 불러 주는 래퍼 함수를 따로 만들어 사용
마치며
프론트엔드 API 관리는 단순히 코드를 정리하는 것을 넘어, 팀 전체의 생산성과 코드 품질을 결정하는 핵심 요소이다.
이 글에서 소개한 전략을 요약하면:
백엔드 API 1개 = 프론트엔드 함수 1개 (Single Source of Truth)
백엔드 Controller 구조와 1:1 매핑 (일관성과 찾기 쉬움)
라이브러리 의존성 격리 (유연한 교체 가능)
컴포넌트에서 비즈니스 로직 처리 (각자의 맥락에 맞게)
OpenAPI 자동 생성 (중복 작업 제거, 항상 동기화)
이 구조를 도입하면:
- API 엔드포인트 변경 시 한 곳만 수정
- 백엔드 스펙 변경 시 컴파일 에러로 즉시 감지
- 새로운 팀원도 API 구조를 빠르게 파악
- 백엔드 팀과의 소통이 원활해짐
자신만의 철학을 만들어가는 중입니다.
댓글남기기