TanStack Query Mutation 유효성 검사 실패 일관되게 처리하기
HTTP 통신은 성공했지만 서버 유효성 검사에 실패한 응답을 예외로 변환하고, 에러 코드와 메시지를 공통화해 TanStack Query mutation의 onError에서 처리했다.
최근 회사에서 신규 서비스와, 신규 서비스를 지탱하기 위한 백오피스 개발 업무를 같이 맡았는데, 백오피스의 개발 업무 중 한 페이지 내에서 다양한 성격의 필드들이 복잡하게 얽혀 있는 부분이 있었다.
이에 따라 유효성 검사도 복잡하고 많은 에러 케이스의 처리가 필요했다. 백오피스에서도 Tanstack Query를 사용하고 있기 때문에 서버 상태 관리와 뮤테이션 작업을 효율적으로 처리할 수 있는 서비스 레이어를 빠르게 구성할 수 있었다.
export const useGetData = (id: number) => {
const queryKey = [...QUERY_KEYS, id]
const queryFn = () => {
return controller
.getData({ id })
.then((res) => {
return new DataModel(res?.result)
})
}
return useQuery(queryKey, queryFn)
}다만 하나 문제가 있었던 것은, 유효성 검사 항목과 에러 케이스가 꽤나 다양했다는 것이다. 서버에서는 유효성 검사 실패를 HTTP 오류 대신 응답 본문의 자체 상태 코드로 전달하고 있었다. 따라서 유효성 검사를 통과하지 못해도 통신 함수의 Promise는 이행되었고, 별도로 예외를 던지지 않으면 catch나 useMutation의 onError에서 처리할 수 없었다.
그래서 통신 후의 결과에 따라 응답을 처리하는 .then() 콜백에서 Error 객체를 던지면, mutation 함수가 반환하는 Promise가 거부되어 onError가 실행될 것이라고 판단했고, 빠르게 테스트해본 결과, 실제로 onSuccess 함수 대신 후속 작업으로 onError 함수가 발동되는 것을 알 수 있었다.
그렇게 처음에는, 통신 함수에서 에러 케이스를 구분했다. 통신에는 성공했으나 백엔드에서 유효성 검사 등으로 인해 예외가 발생한 경우 응답 객체에 result 필드에 null이 내려오고 있었다.
export const useUpdateData = (id: number) => {
const mutateFn = (requestBody: DataUpdateRequest) => {
return controller
.updateData({ requestBody })
.then((res) => {
// 정상적으로 처리되지 않았을 경우, res.result가 null로 오고 있었다.
if (res.result !== null) return res.result
const error = new Error()
error.name = `${res.statusCode}`
error.message = res.message || getErrorMessage(res.statusCode)
throw error
})
}
return useMutation(mutateFn, {
onSuccess() {
client.invalidateQueries([...QUERY_KEYS, id])
client.invalidateQueries([...QUERY_KEYS.LIST])
},
onError() {
console.error('Error occurred!') // triggered!
}
})
}통신 함수에서 에러 객체를 직접 만들어서 던질 경우 onError 함수가 정상적으로 트리거되었다. 하지만, 이렇게 에러 케이스를 연결해주어야 할 API가 적지 않고, 에러 메시지도 다이얼로그나 토스트 UI의 형태로 사용자에게 안내해주어야 했기 때문에 이런 식으로 작업을 계속해서는 좋지 않을 것 같았다.
우리 회사의 프론트엔드 팀은 매 주 프론트엔드 기술이나 개발 트렌드, 혹은 업무를 진행하며 마주친 난항과 해결책같은 것들을 공유하며 서로의 발전을 견인하고 시야를 공유하는 좋은 문화가 있다.
입사 후 많은 이야기가 나왔던 토픽 중 하나는 클래스 문법과 객체지향을 도입하자는 것이었다. 그래서 신규 개발 건 혹은 마이그레이션 작업에 대해서는 점진적으로 클래스를 도입해서 사용하고 있었고, 에러 처리에도 이를 이용해볼 수 있을 것 같았다.
interface IApiResponseObject<T> {
statusCode: number
message: string
result: T | null
}
export class ErrorModel {
private _type: string
private _message: string
private _statusCode: number
constructor(error: IApiResponseObject<unknown> = { statusCode: -1, message: '', result: null }) {
this._type =
ERROR_TYPE_MAP[error.statusCode] ?? '오류'
this._message =
ERROR_MESSAGE_MAP[error.statusCode] ??
(error.message || '잘못된 요청입니다.')
this._statusCode = error.statusCode
}
createError(): never {
const error = new Error()
error.name = this._type
error.message = this._message
error.cause = this._statusCode
throw error
}
get type() {
return this._type
}
get message() {
return this._message
}
get statusCode() {
return this._statusCode
}
}먼저 에러 코드 별 노출시켜야 할 에러 메시지를 상수화시켰고, 그 다음 에러 클래스를 정의했다. 에러 클래스 자체는 복잡한 구조가 필요하지 않았다.
export const useUpdateData = (id: number) => {
const mutateFn = (requestBody: DataUpdateRequest) => {
return controller
.updateData({ requestBody })
.then((res) => {
if (res.result !== null) return res.result
return new ErrorModel(res).createError()
})
}
return useMutation(mutateFn, {
onSuccess() {
client.invalidateQueries([...QUERY_KEYS, id])
client.invalidateQueries([...QUERY_KEYS.LIST])
}
})
}다시 뮤테이션 작업이 필요한 서비스로 돌아가서, 이제 에러 처리를 담당하던 부분을 한 줄의 코드로 한 번에 해결할 수 있게 되었다.
const { mutate: updateData } = useUpdateData(props.id)
/* ... */
const onClickUpdate = () => {
updateData(mutableData.value, {
onSuccess() {
dialog({
ok: true,
title: `수정 완료`,
message: `수정이 성공적으로 완료되었습니다.`
}).onOk(() => {
back()
})
},
onError(error: Error) {
dialog({
ok: true,
title: error.name,
message: `${error.cause} : ${error.message}`,
})
},
})
}
이렇게 유효성 검사 실패를 처리한 경우, onError가 전달받는 에러는 ErrorModel의 createError()에서 생성해 던진 Error 객체이고, 따라서 해당 서비스를 사용하는 컴포넌트 레이어에서도 통신에는 성공했지만 유효성 검사에서 실패한 케이스에 대해, 에러 코드와 메시지를 받아오는 대로 토스트나 다이얼로그와 같은 UI와 함께 처리해줄 수 있다. API마다 직접 작성하던 에러 객체 생성과 코드·메시지 매핑을 ErrorModel로 모을 수 있었다.
덕분에 에러 메시지 구성 규칙이 바뀌면 각 API의 처리 코드를 수정하는 대신 공통 모델과 매핑 상수를 수정하면 됐다. 컴포넌트에서도 서버의 응답 구조를 직접 해석하지 않고, onError로 전달된 에러 정보를 이용해 사용자에게 실패 사유를 안내할 수 있었다.
만약 이 방식을 발전시켜, 백엔드 측에서 예외 케이스 별 에러 코드를 정의하고, 기획 및 운영 측에서 스프레드 시트를 이용해 에러 코드 별로 원하는 에러 메시지를 구성하고, 프론트엔드 측에서는 서버에서 받아온 에러 코드를 앞서 작성한 스프레드 시트와 매핑시킨다면 더욱 적은 배포로 더욱 빠른 수정을 할 수 있고, 다국어 서비스의 지원에도 용이할 것 같다는 생각이 들었다.
이전에는 프론트엔드 개발에서 클래스 문법을 굳이 사용해야 할 이유를 느끼지 못했다. 이번 작업에서는 에러 정보를 구성하는 규칙과 동작을 하나의 모델로 묶으면서 그 쓰임을 경험할 수 있었다. 같은 처리를 함수로 공통화할 수도 있었겠지만, 클래스를 점진적으로 도입하던 팀의 기존 구조에 맞춰 적용하기에도 자연스러웠다. 이번 경험을 통해 도구의 필요성을 미리 단정하기보다, 실제 문제와 팀의 맥락 안에서 판단해보게 됐다.
원문: Medium