본문으로 건너뛰기

Fetch 및 요청 관리

제가 맡은 주제는 Fetch 및 요청 관리입니다.

서버 데이터를 화면에 띄울 때 요청 한 번 보내고 끝나는 경우는 거의 없습니다.

실패하면 원인을 나눠야 하고, 토큰이 만료되면 새로 받아야 합니다. 같은 데이터를 또 받을지 정해야 하고, 검색어가 바뀌면 이전 요청을 정리해야 합니다.

이 네 가지를 순서대로 말씀드리겠습니다. 예시 코드는 제가 회사에서 작업하는 오토위니 웹 프론트엔드에서 줄여서 가져왔고, React, Axios, TanStack Query v5 를 씁니다.

컴포넌트가 useFilterSearch 훅을 부르면 TanStack Query 가 queryKey 로 캐시를 찾고 staleTime, gcTime, retry, signal 을 관리한다. 필요할 때만 Axios 인스턴스 autowiniClient 를 부르고, 요청 인터셉터가 토큰 갱신과 헤더 첨부를 한 뒤 API 서버로 보낸다. 2xx 가 아닌 응답은 reject 되어 돌아오고, fresh 캐시가 있으면 요청 없이 바로 반환한다

먼저 전체 그림입니다.

컴포넌트가 데이터를 달라고 하면 TanStack Query 가 캐시부터 확인합니다. 쓸 만한 캐시가 있으면 요청 없이 바로 돌려주고, 없을 때만 Axios 를 부릅니다.

Axios 는 토큰과 헤더를 붙여서 서버로 보내고, 실패한 응답은 에러로 바꿔서 돌려줍니다. 오늘 이야기는 전부 이 그림 안에 있습니다.


1. HTTP 오류 처리​

1.1 Fetch와 Axios의 차이점​

// fetch
const res = await fetch(`${BASE_URL}/items?keyword=bmw`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404, 500 이어도 여기까지는 정상 진행
const data = await res.json();

// Axios
const { data } = await axios.get(`${BASE_URL}/items`, {
params: { keyword: "bmw" },
headers: { Authorization: `Bearer ${token}` },
}); // 404, 500 이면 여기서 바로 reject

같은 요청을 fetch 와 Axios 로 각각 써 봤습니다.

가장 큰 차이는 404 나 500 이 왔을 때입니다. fetch 는 404 가 와도 Promise 가 성공으로 끝나고, Axios 는 2xx 가 아니면 바로 에러를 던집니다.

fetch 에서 res.ok 확인을 빠뜨리면, 서버가 보낸 에러 메시지를 정상 데이터처럼 화면에 그리게 됩니다.

나머지는 Axios 가 편의 기능을 더 갖고 있다는 차이입니다.

fetchAxios
404·500 응답resolve, res.ok 를 직접 확인reject
JSON 변환res.json() 직접 호출자동
baseURL·공통 헤더함수로 직접 감싸야 함axios.create()
인터셉터없음있음

1.2 HTTP 오류와 네트워크 오류의 차이​

위는 HTTP 오류로, GET /items/999 요청이 서버에 도착하고 서버가 404 Not Found 를 돌려준다. fetch 는 resolve 되고 res.ok 가 false, Axios 는 reject 되고 error.response 가 있다. 아래는 네트워크 오류로, 요청이 도중에 끊겨 응답이 오지 않는다. 원인은 오프라인, DNS 실패, 서버 다운, 타임아웃, CORS 차단이고 fetch 는 TypeError 로 reject, Axios 는 reject 되며 error.response 가 없다

두 오류를 가르는 기준은 서버가 응답을 보냈느냐 하나입니다.

위가 HTTP 오류입니다. 요청이 서버까지 갔고, 서버가 404 처럼 처리할 수 없다는 응답을 보낸 경우입니다.

아래가 네트워크 오류입니다. 인터넷이 끊겼거나 서버가 꺼졌거나 시간 안에 응답이 안 와서, 응답 자체가 없는 경우입니다.

Axios 에서는 error.response 가 있으면 HTTP 오류, 없으면 네트워크 오류입니다.

CORS 는 조금 헷갈립니다. 서버는 응답을 보냈는데 브라우저가 코드에 넘겨주지 않아서, 코드에서는 네트워크 오류와 똑같이 보입니다.

둘을 나누는 이유는 사용자에게 보여 줄 메시지와 재시도 여부가 달라서입니다. 404 는 다시 보내도 404 지만, 네트워크 오류는 다시 보내면 성공할 수 있습니다.

Axios 에서 구분하면 이렇게 됩니다.

import axios from "axios";

const getErrorKind = (error: unknown) => {
if (axios.isCancel(error)) return "canceled"; // 직접 취소한 요청
if (!axios.isAxiosError(error)) return "unknown"; // 요청과 무관한 코드 오류
if (error.response) return "http"; // 서버가 응답함. error.response.status 로 분기
if (error.code === "ECONNABORTED") return "timeout";
return "network"; // 응답 자체가 없음
};

취소를 맨 앞에서 거르는 이유는 취소 에러도 AxiosError 의 한 종류라서입니다. 순서를 바꾸면 직접 취소한 요청이 네트워크 오류로 잡힙니다.


2. Axios 공통 모듈 설계​

2.1 Axios Instance와 Interceptor의 역할​

인스턴스는 설정을 미리 넣어 둔 Axios 입니다. baseURL 이나 기본 헤더를 한 번 넣어 두면 그 인스턴스로 보내는 요청에 모두 적용됩니다.

인터셉터는 요청이 나가기 직전, 응답이 돌아오기 직전에 끼어드는 함수입니다. 요청 쪽에서는 보통 토큰을 붙이고, 응답 쪽에서는 에러를 처리합니다.

저희 회사 코드입니다.

src/api/clients/autowiniClient.ts (줄여서 옮김)
export const autowiniClient = axios.create({
baseURL: import.meta.env.VITE_V2API_URL,
});

autowiniClient.interceptors.request.use(async (config) => {
if (keycloak.authenticated) {
try {
await keycloak.updateToken(60); // 남은 시간이 60초 이하면 먼저 갱신
} catch (error) {
console.warn(
"Token refresh failed, continuing with existing token:",
error,
);
}
}

config.headers = new AxiosHeaders({
...config.headers,
...makeHeaders(), // Authorization: Bearer <토큰> + 국가 코드 헤더
});

return config;
});

요청이 나가기 전에 두 가지를 합니다. 토큰이 60초 안에 만료되면 먼저 갱신하고, 토큰과 국가 코드를 헤더에 붙입니다.

부르는 쪽은 한 줄입니다.

src/api/whoAmI.ts
queryFn: () => autowiniClient.get<WhoAmIdata>("/whoami"),

주소 뒷부분만 적으면 되고, 토큰이나 헤더는 신경 쓰지 않습니다.

인스턴스는 백엔드마다 하나씩, 지금 세 개를 두고 있습니다. 서버마다 주소와 요청 형식이 다르기 때문입니다.

2.2 공통 API 모듈을 분리하는 이유​

공통 모듈이 없으면 요청하는 곳마다 이런 코드가 들어갑니다.

await keycloak.updateToken(60);
const res = await axios.get(`${import.meta.env.VITE_V2API_URL}/whoami`, {
headers: {
Authorization: `Bearer ${keycloak.token}`,
"wini-code-select-country": getCountrySubCode(),
},
});

저희 프로젝트에서 이 인스턴스를 쓰는 파일이 20개입니다.

헤더 하나를 추가하려면 20곳을 고쳐야 하고, 한 곳에서 토큰 갱신을 빠뜨리면 그 화면에서만 401 이 납니다. 공통 모듈로 빼 두면 인터셉터 한 곳만 고치면 됩니다.

파일은 역할별로 세 층으로 나눴습니다.

src/api/clients/autowiniClient.ts 어디로, 어떤 헤더로 보낼지 (인스턴스 + 인터셉터)
src/domains/FilterSearch/api/filter.ts 무엇을 요청할지 (엔드포인트 + useQuery 훅)
src/domains/FilterSearch/index.tsx 언제, 어떻게 보여 줄지 (컴포넌트)

인스턴스는 어디로 어떻게 보낼지, API 파일은 무엇을 요청할지, 컴포넌트는 언제 보여 줄지만 압니다. 백엔드 주소가 바뀌어도 컴포넌트는 건드리지 않습니다.

2.3 401 인증 오류 처리 방식​

401 은 토큰이 없거나 만료돼서 누구인지 확인할 수 없다는 응답입니다.

액세스 토큰은 일부러 유효 기간을 짧게 두기 때문에, 평소에 쓰다가도 만료로 401 이 납니다. 처리 방식은 토큰을 언제 갱신하느냐에 따라 두 가지입니다.

왼쪽은 보내기 전에 갱신하는 방식으로, 요청 인터셉터가 updateToken(60) 으로 만료가 60초 안인지 확인하고 토큰을 갱신한 뒤 GET /wish/v2 를 새 토큰으로 보내 200 OK 를 받는다. 오른쪽은 401 을 받은 뒤 갱신하는 방식으로, 만료된 토큰으로 보내 401 Unauthorized 를 받고 토큰을 갱신한 뒤 같은 요청을 다시 보내 200 OK 를 받는다

왼쪽은 보내기 전에 갱신하는 방식이고, 저희가 쓰는 방식입니다. 보내기 직전에 만료 시간을 보고, 곧 만료되면 갱신부터 한 다음 보냅니다. 실패하는 요청이 생기지 않습니다.

오른쪽은 401 을 받은 뒤 갱신하는 방식입니다. 일단 보내 보고, 401 이 오면 토큰을 갱신해서 같은 요청을 다시 보냅니다.

왼쪽은 간단하지만, 서버가 토큰을 먼저 끊어 버린 경우는 잡지 못합니다. 오른쪽은 401 이면 다 잡지만 챙길 게 많습니다.

저희 코드에서는 앞에서 본 updateToken(60) 이 왼쪽 방식입니다.

요청 10개가 동시에 나가도 갱신은 한 번만 합니다. Keycloak 라이브러리가 갱신 중에 들어온 호출을 모아 뒀다가 한 번에 처리해 주기 때문입니다.

그래도 갱신 자체가 실패하면 401 이 옵니다. 리프레시 토큰까지 만료된 경우입니다. 이건 요청을 보낸 쪽에서 처리하고, 좋아요 버튼이 그 예입니다.

src/components/AutoSearchItem/Like/index.tsx (줄여서 옮김)
onError: (error, nextIsLike) => {
setIsLike(!nextIsLike); // 먼저 바꿔 둔 하트를 되돌림

if (axios.isAxiosError(error) && error.response?.status === 401) {
window.location.href = "/members/join"; // 로그인 흐름으로 보냄
return;
}
alert(t("myWini:temporaryErrorOccurred"));
},

401 이면 로그인 화면으로 보내고, 그 밖의 에러는 알림을 띄웁니다.

오른쪽 방식은 보통 응답 인터셉터에 이렇게 만듭니다. 저희 프로젝트에는 없는 코드라서 일반적인 형태로 가져왔습니다.

let refreshing: Promise<string> | null = null;

client.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) throw error;
original._retry = true; // 새 토큰으로도 401 이면 다시 들어오지 않게

// 401 여러 개가 동시에 와도 갱신 요청은 한 번만 보냄
refreshing ??= refreshToken().finally(() => {
refreshing = null;
});

try {
const token = await refreshing;
original.headers.Authorization = `Bearer ${token}`;
return client(original); // 원래 요청을 새 토큰으로 다시 보냄
} catch {
window.location.href = "/login"; // 갱신도 실패하면 다시 로그인
throw error;
}
},
);

여기서 꼭 챙길 곳이 두 군데입니다.

_retry 는 새 토큰으로 보낸 요청도 401 을 받았을 때 무한 반복하지 않게 막는 표시입니다.

refreshing 은 401 이 동시에 여러 개 와도 갱신 요청을 한 번만 보내게 묶어 줍니다. 이게 없으면 갱신 요청이 여러 번 나가고, 리프레시 토큰을 한 번 쓰면 버리는 서버에서는 두 번째부터 실패해 로그아웃됩니다.


3. TanStack Query와 서버 상태 관리​

3.1 TanStack Query를 사용하는 이유​

상태는 원본이 어디 있느냐로 나눌 수 있습니다.

모달이 열렸는지, 입력 중인 검색어 같은 건 원본이 브라우저에 있는 클라이언트 상태입니다. 매물 목록이나 사용자 정보는 원본이 서버에 있는 서버 상태입니다.

서버 상태는 화면에 있는 값이 복사본이라, 받는 순간부터 서버와 달라질 수 있습니다.

언제 다시 받을지, 여러 컴포넌트가 같은 데이터를 쓸 때 요청을 몇 번 보낼지를 누군가 정해야 하는데, 이걸 TanStack Query 가 맡습니다.

useEffect 로 직접 받으면 이렇게 됩니다.

const [data, setData] = useState<CountryPolicy>();
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);

useEffect(() => {
autowiniClient
.get("/country-policy")
.then((res) => setData(res.data.data))
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
}, []);

컴포넌트 두 곳에서 쓰면 요청이 두 번 나가고, 다른 페이지에 갔다 오면 처음부터 다시 받습니다.

저희 국가 정책 훅은 이렇게 생겼습니다.

src/api/countryPolicy.ts (줄여서 옮김)
export const useCountryPolicy = () => {
const { i18n } = useTranslation();

return useQuery({
queryKey: ["useCountryPolicy", i18n.language], // 언어 변경 시 queryKey가 바뀌어 자동 재요청
queryFn: () =>
autowiniClient.get<CountryPolicyData>("/country-policy", {
headers: { "wini-select-language": i18n.language },
}),
staleTime: Infinity,
gcTime: Infinity,
});
};

queryKey 가 캐시 주소입니다. 같은 키를 쓰면 같은 캐시를 봅니다.

이 훅은 헤더 메뉴, 검색창, 필터 검색 페이지 등 6곳에서 부르지만 요청은 한 번만 나갑니다. 키에 언어가 들어 있어서, 언어를 바꾸면 새 키가 되고 그때 다시 요청합니다.

저희는 서버에서 받은 데이터를 TanStack Query 캐시에만 두고, Zustand 같은 전역 상태로 복사하지 않는 걸 원칙으로 합니다. 복사본이 생기면 어느 쪽이 최신인지 알 수 없게 되기 때문입니다.

3.2 staleTime과 gcTime의 차이​

위 줄은 staleTime 으로, 데이터를 받은 순간부터 staleTime 이 지날 때까지 fresh 라서 요청하지 않고, 그 뒤로는 stale 이라 캐시를 먼저 보여 주고 뒤에서 다시 요청한다. 아래 줄은 gcTime 으로, 컴포넌트가 쓰는 동안에는 지우지 않고 마지막 컴포넌트가 언마운트된 순간부터 기본 5분을 기다린 뒤 캐시에서 삭제한다

둘 다 시간인데 재는 대상이 다릅니다.

위 줄이 staleTime 입니다. 다시 받아야 하는지를 정하는 시간이고, 데이터를 받은 순간부터 잽니다.

이 시간 안이면 fresh 라서 요청을 보내지 않고, 지나면 stale 이 됩니다. 기본값은 0 이라 받자마자 stale 입니다.

아래 줄이 gcTime 입니다. 안 쓰는 데이터를 지워도 되는지를 정하는 시간이고, 그 데이터를 쓰는 컴포넌트가 하나도 없어진 순간부터 잽니다.

기본값은 5분이고, 지나면 캐시에서 지웁니다.

자주 헷갈리는 점이 두 가지 있습니다.

staleTime 이 지나도 바로 요청이 나가지는 않습니다. stale 은 표시일 뿐이고, 컴포넌트가 새로 마운트되거나 창에 다시 포커스가 오거나 네트워크가 다시 연결될 때 요청합니다.

gcTime 은 화면에서 쓰는 동안에는 시작도 하지 않습니다. 보고 있는 데이터가 5분 뒤에 사라지는 일은 없습니다.

같은 화면에 다시 들어왔을 때를 기준으로 보면 이렇습니다.

다시 들어온 시점화면요청
staleTime 안캐시를 바로 보여 줌안 보냄
staleTime 지남, gcTime 안캐시를 바로 보여 줌뒤에서 보냄
gcTime 지남로딩처음부터 보냄

저희는 데이터 성격에 따라 나눠 씁니다.

국가 코드나 국가 정책처럼 세션 동안 바뀌지 않는 데이터는 두 값 모두 Infinity 로 둬서 한 번만 받습니다. 매물 검색 결과는 수시로 바뀌니까 기본값을 그대로 씁니다.

3.3 Axios와 TanStack Query의 역할 차이​

처음 그림으로 돌아가 보면, 둘은 맡은 층이 다릅니다.

AxiosTanStack Query
맡는 일요청을 보내고 응답을 받음받은 데이터를 보관하고 언제 다시 받을지 정함
아는 것주소, 헤더, 토큰, 상태 코드캐시 키, 로딩·에러 상태, 신선도
대신 쓸 수 있는 것fetchuseEffect + useState 직접 구현

TanStack Query 는 HTTP 를 모릅니다. queryFn 이 Promise 만 돌려주면 되니까 Axios 대신 fetch 를 써도 됩니다.

여기서 1번의 fetch 이야기가 다시 나옵니다. TanStack Query 는 queryFn 이 에러를 던져야 실패로 압니다.

fetch 로 쓰고 res.ok 확인을 빼먹으면, 500 에러 응답이 성공 데이터로 캐시에 들어갑니다.

queryFn: async () => {
const res = await fetch("/api/items");
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 이 줄이 없으면 500 도 성공으로 캐시
return res.json();
},

Axios 는 2xx 가 아니면 알아서 에러를 던지니까 이 줄이 필요 없습니다.


4. 요청 취소 및 재시도​

4.1 AbortController의 동작 원리​

AbortController 는 두 부분으로 되어 있습니다. 취소를 거는 abort() 와, 취소됐는지 알려 주는 signal 입니다.

signal 을 요청에 넘겨 두고 abort() 를 부르면 signal 에서 abort 이벤트가 납니다. 요청을 받은 쪽은 이 이벤트를 듣고 있다가 하던 일을 멈춥니다.

fetch 안에서 일어나는 일을 타이머로 흉내 내면 이렇습니다.

const wait = (ms, signal) =>
new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);

signal.addEventListener("abort", () => {
clearTimeout(timer); // 하던 일을 멈추고
reject(signal.reason); // abort() 에 인자가 없으면 reason 은 AbortError
});
});

const controller = new AbortController();
wait(3000, controller.signal).catch((e) => console.log(e.name)); // 'AbortError'
controller.abort();

signal 에 abort 이벤트를 걸어 두고, 이벤트가 오면 타이머를 멈추고 reject 합니다. fetch 도 똑같이 연결을 끊고 AbortError 로 reject 합니다.

한 번 abort 한 controller 는 다시 쓸 수 없어서 요청마다 새로 만듭니다.

주의할 점이 하나 있습니다. 취소는 브라우저가 응답을 버리는 것이지, 서버가 처리를 멈추는 게 아닙니다.

주문 요청을 취소해도 서버에 이미 도착했다면 주문은 생길 수 있습니다.

React 에서는 useEffect 정리 함수에서 취소합니다.

useEffect(() => {
const controller = new AbortController();

autowiniClient
.get("/items", { params: { keyword }, signal: controller.signal })
.then((res) => setItems(res.data.data.items))
.catch((e) => {
if (!axios.isCancel(e)) setIsError(true);
});

return () => controller.abort(); // keyword 가 바뀌거나 화면을 떠나면 이전 요청을 취소
}, [keyword]);

검색어가 바뀌면 정리 함수가 실행되면서 이전 요청을 끊습니다.

TanStack Query 는 queryFn 에 signal 을 넣어 줍니다. 이걸 Axios 에 넘기면 키가 바뀌거나 화면을 떠날 때 알아서 취소합니다.

queryFn: ({ signal }) => autowiniClient.get("/items", { params, signal }),

저희 코드는 아직 signal 을 넘기지 않습니다. 넘기지 않으면 취소하지 않고 끝까지 받아서 캐시에 넣습니다.

4.2 Race Condition 발생 원인과 해결 방법​

검색창에 bmw 를 입력해 요청 A 를 보내고 곧바로 benz 로 바꿔 요청 B 를 보낸다. 서버에서 A 는 오래 걸리고 B 는 금방 끝나서 B 응답이 먼저 와 목록이 benz 결과로 바뀐다. 그 뒤 A 응답이 늦게 도착해 목록을 bmw 결과로 덮어써서, 검색창은 benz 인데 목록은 bmw 매물이 된다

검색창에 bmw 를 쳤다가 바로 benz 로 바꾼 상황입니다.

bmw 요청이 먼저 나가고, benz 요청이 나중에 나갑니다. 그런데 bmw 쪽 처리가 오래 걸려서 benz 응답이 먼저 오고, 화면에 benz 결과가 뜹니다.

그다음 bmw 응답이 늦게 도착해서 목록을 덮어씁니다. 검색창은 benz 인데 목록은 bmw 매물이 됩니다.

원인은 응답이 보낸 순서대로 오지 않는데, 코드가 도착한 응답을 확인 없이 화면에 넣기 때문입니다. 해결 방법은 세 가지입니다.

방법늦게 온 bmw 응답
이전 요청 취소 (AbortController)요청이 끊겨서 도착하지 않음
무시 플래그 (let ignore)도착하지만 버림
키별로 저장 (TanStack Query)bmw 칸에 저장되고, 화면은 benz 칸만 읽음

이전 요청 취소는 방금 본 AbortController 코드입니다.

무시 플래그는 React 공식 문서에 나오는 방식입니다. 요청은 그대로 두고, 늦게 온 응답만 버립니다.

useEffect(() => {
let ignore = false;

autowiniClient.get("/items", { params: { keyword } }).then((res) => {
if (!ignore) setItems(res.data.data.items); // 이미 다음 검색어로 넘어갔으면 버림
});

return () => {
ignore = true;
};
}, [keyword]);

TanStack Query 는 따로 할 게 없습니다. 결과를 키별로 저장하니까 늦게 온 bmw 응답은 bmw 칸에 들어가고, 화면은 benz 칸만 봅니다.

저희 필터 검색 훅도 검색 조건 전체를 키에 넣고 있어서, signal 없이도 결과가 섞이지 않습니다.

src/domains/FilterSearch/api/filter.ts
const { data, isLoading, isError } = useQuery({
queryKey: ["useFilterSearch", itemType, useFilterSearchParams], // 조건이 바뀌면 다른 캐시
queryFn: () =>
autowiniClient.get<FilterData>(`/items/${itemType}/filters`, {
params: useFilterSearchParams,
}),
enabled: !!itemType,
placeholderData: (prev) => prev, // 새 결과가 오기 전까지 이전 결과를 보여 줌
});

placeholderData 는 조건을 바꿨을 때 새 결과가 올 때까지 이전 결과를 보여 주는 옵션입니다. 이게 없으면 목록이 로딩으로 깜빡였다가 다시 채워집니다.

4.3 Retry와 Idempotency 개념​

재시도는 실패한 요청을 다시 보내는 겁니다. 다시 보냈을 때 결과가 바뀔 수 있는 실패에만 의미가 있습니다.

네트워크 오류나 503 같은 일시적인 서버 오류는 다시 보내면 성공할 수 있습니다. 400, 401, 404 는 몇 번을 보내도 결과가 같습니다.

TanStack Query 는 기본으로 실패하면 3번 더 보내고, 간격은 1초, 2초, 4초로 늘어납니다.

문제는 상태 코드를 가리지 않는다는 점입니다. 404 도 세 번 더 보내서 에러 화면이 7초쯤 뒤에 뜹니다.

저희는 전역 설정에서 재시도를 꺼 뒀습니다.

src/main.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // 창 포커스 때 다시 받지 않음
retry: false, // 실패해도 다시 보내지 않음
},
},
});

재시도를 켠다면 이렇게 4xx 는 빼는 게 좋습니다.

retry: (failureCount, error) => {
// 서버가 4xx 로 답했으면 다시 보내도 같은 결과
if (axios.isAxiosError(error) && error.response && error.response.status < 500) return false;
return failureCount < 2; // 네트워크 오류와 5xx 는 2번까지
},

다음은 멱등성입니다. 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같은 성질을 말합니다.

GET, PUT, DELETE 는 멱등하고 POST 는 그렇지 않습니다.

재시도에서 이게 왜 중요한지는 이 그림으로 설명할 수 있습니다.

왼쪽은 키 없이 다시 보내는 경우로, POST /orders 가 도착해 주문 #1 이 생기지만 201 응답이 유실되고, 클라이언트가 타임아웃을 실패로 보고 다시 보내 서버가 주문 #2 를 만든다. 오른쪽은 Key: a1 을 붙여 보내는 경우로, 서버가 주문 #1 을 만들며 a1 을 저장하고, 같은 a1 으로 재시도가 오면 새로 만들지 않고 주문 #1 결과를 돌려준다

왼쪽은 주문 요청이 서버에 도착해서 주문 1번이 생겼는데, 응답이 돌아오다가 끊긴 경우입니다.

클라이언트는 실패한 줄 알고 다시 보내고, 서버는 주문을 하나 더 만들어서 주문이 2건이 됩니다.

오른쪽은 Idempotency-Key 를 붙인 경우입니다. 요청마다 고유한 키를 붙이고, 서버는 처리한 키를 기억해 둡니다.

같은 키로 다시 오면 새로 만들지 않고 처음 결과를 돌려줍니다. 서버가 이 키를 지원해야 동작합니다.

const { mutate, isPending } = useMutation({
mutationFn: ({ body, key }: { body: OrderBody; key: string }) =>
client.post("/orders", body, { headers: { "Idempotency-Key": key } }),
retry: 2, // 재시도는 같은 variables 로 다시 부르므로 키도 같음
});

// 키는 버튼을 누를 때 한 번 만듦
<button
disabled={isPending}
onClick={() => mutate({ body, key: crypto.randomUUID() })}
>
주문하기
</button>;

키는 버튼을 누를 때 한 번 만들어서 넘깁니다. mutationFn 안에서 만들면 재시도할 때마다 키가 바뀌어서 소용이 없습니다.

disabled={isPending} 은 사용자가 버튼을 두 번 누르는 걸 막는 용도입니다.