Tanstack Query Mutation 유효성 검사 실패 편하게 처리하기
TanStack Query mutation에서 서버 유효성 검사 실패를 Error와 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 통신 실패가 아닌 유효성 검사에서는 자체적인 상태 코드를 사용하고 있었기 때문에, 뮤테이션 작업을 시도했을 때 유효성 검사는 통과하지 못했어도 통신 자체는 성공한 것으로 인식되어 결과에 따른 분기처리를 위해 try ~ catch 문법을 이용해 포착해서 처리하도 어렵고, useMutation의 mutate 함수의 onError 후속 작업 함수를 발동시키기도 까다로웠다.
그래서 통신 후의 결과에 따라 Error 생성자 함수를 통해 Error 객체를 반환하면 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) 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 = { statusCode: -1, message: '' }) {
this.type =
ERROR_TYPE_MAP[error.statusCode] ?? '오류'
this.message =
ERROR_MESSAGE_MAP[error.statusCode] ??
(error.message || '잘못된 요청입니다.')
this.statusCode = error.statusCode
}
createError() {
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) 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 함수가 인자로 건네 받는 에러는 에러 클래스로 정의한 객체고, 따라서 해당 서비스를 사용하는 컴포넌트 레이어에서도 통신에는 성공했지만 유효성 검사에서 실패한 케이스에 대해, 에러 코드와 메시지를 받아오는 대로 토스트나 다이얼로그와 같은 UI와 함께 처리해줄 수 있다. 서비스 레이어와 컴포넌트 레이어에서의 코드 복잡성이 낮아지고 가독성도 개선되는 부가 효과도 있었다.
만약 이 방식을 발전시켜, 백엔드 측에서 예외 케이스 별 에러 코드를 정의하고, 기획 및 운영 측에서 스프레드 시트를 이용해 에러 코드 별로 원하는 에러 메시지를 구성하고, 프론트엔드 측에서는 서버에서 받아온 에러 코드를 앞서 작성한 스프레드 시트와 매핑시킨다면 더욱 적은 배포로 더욱 빠른 수정을 할 수 있고, 다국어 서비스의 지원에도 용이할 것 같다는 생각이 들었다.
여태 클래스 문법이 프론트엔드 개발에서는 신택스 슈가일 뿐, 굳이 사용할 필요성을 못 느끼고 있었는데, 개발 효율을 찾기 위해 이런 방식으로 활용하게 될 줄 몰랐다. 확실히, 필요 없는 도구와 기술은 없고, 어떠한 도구와 기술이 등장한 데에는 모두 히스토리가 있다는 것을 다시금 느끼게 되는 계기였다.
원문: Medium