API 에러 응답을 통일하며 배운 것들
배경
지금 회사에서 하는 프로젝트는 백엔드가 Supabase 기반 NestJS, 프론트엔드는 React이다. 프론트는 서버에 ky fetcher 기반으로 요청을 보내고 돌아온 응답과 에러를 화면에 그려주고 있다. 이 글에선 프론트와 서버 사이를 오가는 에러에 대해 다룬다.
문제점을 인식한 시점은 API 호출 부분을 작성하던 때였다. 백엔드에 코드를 보내고 결과를 받는 그뿐인 기능인데 호출부 코드가 이상하게 길어졌다. 돌이켜보면 일단 굴러가게 만드는 데만 급급했던 것 같다. 그러는 사이 미처 못 챙기고 지나친 것들이 조금씩 쌓였고 이 긴 호출부가 딱 그 흔적이었다.
const response = await fetcher.post('users/me/coupons', {
json: {code},
throwHttpErrors: false,
});
if (response.ok) return {success: true};
if (response.status === 400) {
const body = (await response.json().catch(() => null)) as {
errorCode?: string;
} | null;
const errorCode = body?.errorCode;
if (errorCode === 'COUPON_EXPIRED' || errorCode === 'COUPON_ALREADY_USED') {
return {success: false, error: errorCode};
}
return {success: false, error: 'INVALID_CODE'};
}
throw new Error(`쿠폰 코드 등록 실패: ${response.status}`);비즈니스 분기 자체는 단순했다. 성공인지, 만료된 쿠폰인지, 이미 쓴 쿠폰인지만 가르면 그만이었다. 그런데 그 위에 throwHttpErrors: false로 에러를 끄고 응답을 json()으로 까서 캐스팅하고, status로 나누는 파싱 보일러플레이트가 생겼다. 다른 API 호출에 이걸 또 베껴 쓴다고 생각하니, 한 번 정리하고 넘어가야겠다 싶었다.
그래서 에러 형태를 통합하고 한 곳에서 처리하자는 이슈를 생성했다.
그런데 파고들수록 이건 프론트만의 문제가 아니었다. 백엔드가 주는 에러 응답부터가 한 가지 모양이 아니었다(뒤에서 다룬다). 그래서 작업이 두 갈래로 나뉘었다. 먼저 백엔드에서 에러 형태를 하나로 맞추고, 프론트는 그 통일된 형태만 믿고 받아 쓰는 얇은 어댑터를 두기로 했다.
백엔드 에러 응답을 하나의 형태로
세 갈래로 갈려 있던 응답
우리 백엔드에는 전역 에러 필터가 없었다. 그래서 어디서 예외가 났느냐, 작업한 사람에 따라 응답 body 모양이 달랐다.
// 1) 문자열 인자 예외 — throw new NotFoundException('회원 정보를 찾을 수 없습니다')
{ "statusCode": 404, "message": "회원 정보를 찾을 수 없습니다", "error": "Not Found" }
// 2) DTO 검증 실패 — ValidationPipe가 자동으로 BadRequestException 생성 (message가 영문 배열)
{ "statusCode": 400, "message": ["code must match /^.../ regular expression"], "error": "Bad Request" }
// 3) 객체 인자 예외 — coupon 계열, statusCode가 아예 없음
{ "errorCode": "COUPON_EXPIRED", "message": "만료된 쿠폰입니다" }어떤 건 message가 한글이라 그대로 토스트에 띄워도 되는데 어떤 건 ["code must match /^.../ ..."] 같은 영문 배열이고, 또 어떤 건 statusCode조차 없다. 결국 호출부마다 "이건 이렇게, 저건 저렇게" 하는 분기가 쌓일 수밖에 없었다.
전역 필터와 ky의 beforeError 옵션
NestJS는 핸들러가 예외를 던지면 내장 exception layer가 그걸 받아 HTTP 응답으로 바꿔준다. 여기에 @Catch() 필터를 app.useGlobalFilters()로 걸어두면 모든 예외가 밖으로 나가기 직전에 한 곳에서 처리될 수 있다. 에러를 던지는 쪽은 그대로 두고 나가는 모양만 한 군데서 맞추는 셈이다.
프론트 쪽 대응 도구는 ky의 beforeError 훅이었다. ky가 에러를 던지기 직전에 끼어들어 응답 body를 파싱해 우리만의 ApiError형태로 변환해준다. 호출부는 ky의 날 것 형태의 에러 대신 항상 우리가 합의한 형태의 ApiError만 보면 된다. 적어도 계획은 그랬다.
어디서 고칠지 정하기
선택지는 다음과 같았다.
- (a) 프론트만 —
beforeError에서 세 가지 모양을 하나로 처리. 빠르지만 근본 원인(백엔드)이 남아서, 네 번째 형태가 생기면 더 깨진다. - (b) 백엔드만 — 전역 필터로 응답을 통일. 대신 프론트 보일러플레이트는 그대로.
- (c) 백엔드에서 에러 응답을 통일하고 프론트는 그 통일된 형태를 신뢰하는 얇은 어댑터를 둔다
나는 (c)를 택했다. 모양이 갈리는 진짜 원인이 백엔드에 있었기 때문이다. 프론트는 응답 형태 하나만 고려하면 되니 훨씬 단순해진다.
이어서 더 고민됐던 건 사용자에게 보여줄 에러 message를 누가 소유하느냐였다.
- (A) 백엔드
message를 그대로 사용자 카피로 토스트 — 검증 실패가 영문 배열로 오고 5xx엔 내부 정보가 섞일 수 있다. 그러려면 백엔드 메시지를 전부 사람 말로 다듬는 게 먼저였는데 품이 너무 컸다. - (B)
message는 디버깅/로깅용으로 두고 사용자 문구는 프론트가errorCode/status분기로 처리
나는 (B)를 택했다.
백엔드의 에러 메시지와 프론트에서 UI에 보여주는 에러 메시지의 성격이 다르다고 생각하고 B가 더 가볍고 적은 수정으로 처리할 수 있을 것으로 기대했다.
모든 에러가 한 모양으로
필터를 통과한 뒤로는 모든 에러 응답이 아래 한 가지 모양으로 나간다.
// 일반 4xx — throw new NotFoundException('...') (기존 호출부 무변경)
{ "statusCode": 404, "message": "회원 정보를 찾을 수 없습니다" }
// 클라이언트가 분기해야 할 때만 errorCode를 옵트인으로 붙인다
{ "statusCode": 400, "errorCode": "COUPON_EXPIRED", "message": "만료된 쿠폰입니다" }검증 실패라면 여기에 errorCode: "VALIDATION_FAILED"와 원본 사유 배열(details)이 더 붙는다. 5xx는 내부 정보가 새지 않게 일반 문구로 가리고, 원문은 서버 로그에만 남긴다.

아직 통합되지 않은 에러
에러 응답은 통일했지만 프론트에서 여전히 분기가 필요했다.
ky가 끝내 못 잡는 전송 에러
프론트 beforeError에서 응답을 ApiError로 바꿔두면 호출부는 깔끔할 줄 알았다. 그런데 코드 리뷰에서 이런 지적이 들어왔다.

맞는 말이었다. ky 소스를 따라가 보니 beforeError는 정말로 HTTP 응답이 돌아왔을 때만 불린다. 응답 자체가 없는 타임아웃, 네트워크 끊김은 훅을 그냥 지나쳐서, 호출부엔 ky의 날것 에러가 떨어진다. 그래서 결국 호출부마다 이렇게 됐다.
onError: (error) => {
toast.error(
error instanceof ApiError
? error.message
: '입금 추가에 실패했습니다. 잠시 후 다시 시도해주세요.', // 전송 에러 폴백
);
};에러 모양을 통일하겠다고 시작한 작업인데, 정작 호출부에서 "이건 ApiError냐 아니냐"를 매번 다시 따지고 있었다. 통일이 한 겹 덜 된 느낌이었다.
나만 고통받은 게 아니었다!
처음엔 이걸 toUserMessage(err, { messages, fallback }) 같은 헬퍼 하나로 모았다. 작성하다 보니 "에러 한 번에 처리하려고 beforeError를 뒀는데, 왜 호출부에서 또 한 번 정돈해야 하지?"라는 찜찜함이 가시질 않았다.
AI 에이전트한테도 물어봤는데 자꾸 React Query의 전역 에러를 통해 해결하려고 했다. 그것도 맞을 수 있지만 내가 찾는 포인트는 그게 아니었다. 답답해서 그냥 ky 이슈를 직접 뒤졌다.
그러다 #508에서 내 고민과 똑같은 토론을 찾았다. "beforeError가 전송 에러는 못 잡는다"는 바로 그 이야기였고, 길게 논의된 끝에 2달 전 ky v2에 반영돼 있었다.

확인해보니 ky 2.0은 달랐다. beforeError가 HTTP 에러뿐 아니라 전송 에러(타임 아웃, 네트워크 끊김)까지 받고, 응답 body도 훅이 돌기 전에 미리 파싱해서 error.data로 넘겨준다. 그러니까 호출부로 새던 에러들을, 이제 훅 안에서 한 번에 처리할 수 있다는 얘기였다.
헬퍼를 걷어내고 훅에서 끝냈다
ky를 ^1.7.5에서 ^2.0.2로 올리고, 훅 하나가 HTTP 에러와 전송 에러를 모두 정돈하도록 바꿨다. 메이저 업그레이드라 손봐야할 지점이 있었다.
- 훅 시그니처가 인자 하나에서 객체 구조분해로 바뀌었다.
(error)가({ error })가 됐다. - 기본 URL 옵션 이름이
prefixUrl에서prefix로 바뀌었다. - 응답 본문을 꺼내는 방식이
error.response.json()에서error.data로 달라졌다.
그래서 fetcher 두 곳과 searchParams를 쓰던 자리를 한 번씩 회귀 점검해야 했다.
// before (ky 1.7.5) — HTTP 응답이 있을 때만 발동
const toApiError: BeforeErrorHook = async (error) => {
const body = await error.response
.clone()
.json()
.catch(() => null);
return new ApiError(error, body);
};// after (ky 2.0.2) — 전송 에러까지 한 자리에서 정돈
const TRANSPORT_ERROR_MESSAGE =
'네트워크 연결이 불안정합니다. 잠시 후 다시 시도해주세요.';
const normalizeError: BeforeErrorHook = ({error}) => {
if (isHTTPError(error)) {
const body =
typeof error.data === 'object' && error.data !== null
? (error.data as Partial<ApiErrorBody>)
: null;
return new ApiError(error, body);
}
// 타임아웃·연결 끊김: 영문 메시지·내부 URL을 한글 안내로 마스킹
error.message = TRANSPORT_ERROR_MESSAGE;
return error;
};결과적으로 아까 그 호출부는 instanceof 분기도, 폴백 문구도 없이 이 한 줄로 줄었다.
onError: (error) => {
toast.error(error.message);
};결과와 배운 점
- 에러 모양을 한 군데에서 통일하니, 그 뒤의 코드가 단순해졌다. 백엔드는 필터에서, 프론트는 훅에서. 호출부는 그저 통일된 형태만 믿으면 됐다.
- 막혔을 때 추상화를 더 쌓기 전에 도구 자체를 의심했다. 호출부 보일러플레이트를 헬퍼로 덮는 건 증상만 가리는 쪽에 가까웠다. 진짜 원인은 'ky 제약'이었고, 그건 헬퍼로 덮어서 풀 문제가 아니었다.
- 에이전트가 주는 답도 곧이곧대로 받지 않았다. 코딩 에이전트는 자꾸 React Query 전역 에러 핸들러로 처리하는 길을 권했는데(그렇게 푸는 사례도 있는 듯하다), 그대로 따랐다면 돌아가긴 해도 구조만 더 복잡해졌을 것이다. 내가 풀려는 게 정확히 뭔지부터 분명히 하고 접근한 덕에 진짜 원인까지 닿을 수 있었다.
- 그리고 답은 이미 누군가 토론해 둔 곳에 있었다. 에이전트와 한참 돌다가 이슈를 직접 검색하니 금방 같은 고민과 결론을 만났다. 막힐 땐 그 라이브러리의 issue를 먼저 잘 뒤져봐야겠다 느꼈다.
남은 숙제
나중에 타임라인을 맞춰보니, 내가 헤매다 만난 그 토론은 이미 PR로 머지돼 ky 2.0.0에 두 달쯤 전에 릴리스돼 있었다. 답이 두 달이나 먼저 나와 있었는데, 나는 그걸 모른 채 한참을 돌았던 셈이다. 평소에 의존성 업데이트만 조금 챙겼어도 훨씬 빨리 닿았을 텐데. 그게 좀 아쉬웠다. 그동안 라이브러리 버전 변화를 크게 신경 쓰지 못했는데, 다른 분들은 changelog나 릴리스를 어떻게 따라가는지 궁금해졌다.