React 프로젝트 중 Apollo client 이용시 error handling에 대해
안녕하세요. 코드스테이츠 flex-immersive 1기 프로젝트를 진행하며 apollo client를 사용했는데 사용하며 느낀 점들에 대해서 글을 쓰게 되었습니다. 이번에는 Apollo client에서 error 핸들링 방법들을 정리해보려 합니다.
저는 typescript, react hooks와 apollo client(v2.6)를 이용하여 작업을 하였고, 서버쪽은 apollo-server로 작업하였습니다.
이번 글에서는
1. Apollo client에서의 error 소개
2. error의 종류와 각각에 대한 handling 방법
3. 다른 제안되는 방법
4. 회고
순서로 얘기해보겠습니다.
1. Apollo client에서의 error 소개
apollo client를 처음 사용하였을 때 저는 어떤 error가 발생했는지를 파악하기도 어려웠습니다. 아래그림처럼 다 INTERNAL SERVER ERROR라고 같은 error code만 오고, error의 message 조차도 받기 힘들었기 때문입니다.
조금 찾아보니 apollo client에서 error는 2가지 위치에서 받고 처리할 수가 있었습니다. 첫째는 apollo-link-error를 이용하여 서버와 클라이언트가 통신하는 부분 (이하 top level ) 에서 handling할수가 있었고, 둘째는 react code level (이하 code level )에서 실제 쿼리가 실행될때 error 결과값을 받아 처리할 수가 있었습니다.
첫 번째 top level에서 처리를 하기 위해선 아래 그림과 같은 설정이 필요하고, 이를 통해 프로젝트 시작 2주만에야 console창에 error message를 받을 수 있었습니다.
<출처: https://www.apollographql.com/docs/link/links/error/ >
하지만 실제 error 처리가 더 많이 일어나는 부분은 2번째인 code level로 생각되는데 이 쪽에서 error에 대해 더 정보를 받고, 메세지를 처리하기 위해선 서버쪽에서의 설정도 필요합니다. 서버쪽의 설정과 이후 error가 어떤 형태로 들어오는지에 대해선 https://www.daleseo.com/graphql-apollo-server-errors/ 블로그에서 잘 설명이 되어 있습니다. client 입장에선 errors.extension.code 나 errors.message를 통하여 routing 처리를 할 수 있습니다.
2. error의 종류에 따른 추천 handling 방법과 그외 방법 소개
여기까지 알았더니 이제 언제는 top-level에서 handling하고, 언제는 code level에서 하는게 좋은가에 대한 의문이 떠오르게 됩니다. 이 부분에 대해서는 https://blog.apollographql.com/full-stack-error-handling-with-graphql-apollo-5c12da407210 블로그에서 답을 찾을 수 있었습니다.
error 처리는 가급적 유저의 view layer에 영향을 안 줘서 UX를 향상시키는 방향으로 하게 되는데 이 기준에 따라 일시적이고, 회복가능한 error는 유저의 view에 닿기전인 apollo-link에서 처리하고, 나머지는 각 code문 안에서 해결하는 방향을 제안하고 있습니다.
대표적 예시로 token 만료로 인한 authentication error시에는 만료가 됐을경우 자동으로 갱신시키는 logic을 apollo-link-error에 넣는다면 error가 통신 level에서 이미 해결되어 user는 그 error가 발생했는지에 대해 알아차리지도 못하게 됩니다. 아래 그림에서 보면 client로 error가 전달되기전에 ReauthenticationLink에서 new token을 가지고 다시 retry를 하게 됩니다.
<출처: https://blog.apollographql.com/full-stack-error-handling-with-graphql-apollo-5c12da407210 >
또한 위 블로그에서는 거기서 그치지 않고, error를 2가지 기준으로 나눠 각 error에 대한 처리법도 제안하고 있는데 첫째 기준은 client / server 누구의 잘못으로 발생한 error인지이고, 둘째는 요청이 resolver에 닿기도 전에 발생하는 network error인지 혹은 이후 resolver 실행과정에서 발생하는 graphQL error인지 입니다.
우선 첫째 기준인 client error / server error의 분류로 볼 때 client의 error일 경우 (가령 query문의 variable에 엉뚱한 data type을 넣는 경우)는 가급적 애초 서버로 불필요한 query가 날라가기전에 client에서의 type validation 등을 더 강화시켜 해서 서버 resource 소모를 줄이고, user에게 더 빠른 feedback을 주는 것을 추천하고 있습니다. 이번 프로젝트의 경우는 typescript를 썼기 때문에 이 type checking 부분은 문제가 없었습니다.
둘째 기준인 network error / graphQL error의 관점에서는 partial failure 라는 측면에서 접근을 하고 있습니다.
partial failure란 query시 어떤 data는 받아오는데 성공하고, 어떤 data는 받아오는데 실패한 것을 말합니다.
애초 resolver에 접근하지 못한 network error의 경우에는 모든 data에 대해 값을 받지 못해 이런 경우가 발생하지 않겠지만, graphQL error인 경우는 이런 상황이 발생할 수 있습니다.
가령 아래와 같은 query가 있다고 한다면 여러개의 data를 각각의 resolver에서 취합하는 graphQL 특성상 어떤 값은 정상적으로 들어오고, 어떤 값은 error가 생겨 받지 못할 수 있습니다. username은 정상적으로 입력했으나 email은 형식에 맞게 입력하지 않은 경우 이때 결과값으로 data에 username은 들어와 있을 것이나, email은 null이 들어올 것이고, error에 email과 관련된 내용이 추가됩니다.
{
user(username:"yonggyu" email:"wrongmail%gmail.com") {
username
email
}
}
이런 경우가 발생할 때 원하는 방향으로 이끄는 것은 apollo client의 errorPolicy
옵션과 schema 정의시 nullability
설정을 통해 가능합니다.
nullability
설정은 schema에서 어떤 항목에 data가 없을 경우 error를 일으킬지말지에 대한 부분이고, errorPoliy
는 https://www.apollographql.com/docs/react/data/error-handling/ 공식 사이트에 잘 설명되어있는데 간략히는 partial failure일때 그때 발생하는 error값과 data값을 어떻게 처리할지에 대한 option입니다. 결국 위 2가지를 조합하면 partial failure시에 특정값만 올바르게 들어올경우 어떻게 처리할지에 대해 원칙을 만들 수 있습니다.
전 이번에 errorPolicy: 'none'으로 partial failure의 경우 data 일체를 받지 않고 error 처리를 하였지만, data의 non-null 항목에 대해 좀 더 유연성을 주고자 했다면 errorPolicy를 'all'로 하고, non-nullable data가 null일 때만 error를 발생시키는 방법을 사용할 수 있었습니다.
아래 그림은 앞서 말씀드린 것들을 요약한 순서도로 앞서 언급한 첫째 기준에 의해 server error, client error (= request error)로 분류가 되어있고, 이 error들은 network error, graphQL error가 혼재되어 있습니다. 가장 아래쪽의 partial failure는 전부 graphQL error입니다.
<출처: https://blog.apollographql.com/full-stack-error-handling-with-graphql-apollo-5c12da407210 >
3. 다른 제안되는 방법
이래서 error handling에 대해서는 정리가 되었나 싶었는데 위와 같은 가이드가 있음에도 불구하고 서치를 하다보니 아래와 같은 방법들이 최근 제안되고 있음을 알 수 있었습니다.
https://www.youtube.com/watch?v=A5-H6MtTvqk,
https://blog.logrocket.com/handling-graphql-errors-like-a-champ-with-unions-and-interfaces/
https://itnext.io/the-definitive-guide-to-handling-graphql-errors-e0c58b52b5e1
위 블로그 및 동영상에 따르면 기존의 graphQL error 처리 방법은 아래와 같은 문제점을 가지고 있습니다.
- error의 path가 뜨긴 하지만 어디서 발생했는지 쉽게 파악하기 어렵다.
- errors.extension.code 나 errors.message를 통해 전달이 될 때 GraphQL의 장점인 type safety를 살리기 어렵다.
- 모든 error가 다 똑같이 처리된다.
- 어떤 error를 신경써야 하고, 어떤 error는 무시할지 알 수 없다.
위의 4가지 문제를 한번에 해결할 해결책으로 제시되고 있는 것이 우리가 다뤄야 하는 error라면 graphQL의 data 안에 원래 받아야할 data값과 발생할 수 있는 error의 종류들을 union type 으로 묶어서 넣는 것입니다.
좀 더 상세히 말하면 위 방법에서는 error를 something went wrong (이하 unintended error
) / nothing went wrong (이하 intended error
)으로 나누고 있습니다.
가령 아래와 같은 쿼리가 있다고 한다면 발생할 수 있는 error의 종류에는 여러가지가 있을 것입니다.
{
user(username:"yonggyu" email:"yonggyu@gmail.com") {
username
email
}
}
이중 unintended error
란 실제 무엇이 잘못되어서 발생한 error로 예를 들자면 bad gateway, internal server error 같은 개발쪽의 unexpected error 입니다.
반면 intended error
은 우리가 user값을 요청했을 때 받을 수 있는 deleted user, unavailable in country, suspended user 같은 경우입니다. 이는 무언가 잘못되어 발생했다기보다는 다분히 의도된, 위 값들 자체가 하나의 data이고, error라기보다는 data 값에 들어가야한다는 것이 위 방법론의 요지입니다.
intended error
를 errors 배열이 아니라 data에 넣을 경우 unexpected error만이 errors의 배열 안에 들어오게 됩니다. intended error
들은 우리가 따로 관리할 대상으로, 항상 data로 들어오며 어떤 query의 결과값으로 들어온 것인지가 명확하기 때문에 출처를 알기가 쉽습니다.
그럼 어떻게 넣을 것이냐에 대해서 아래 예시를 보면
{
type User {
username: String!
email: String!
}
type UserBlockedError {
message: String!
blockedByWho: User
}
type UserDeletedError {
reason: String
}
union UserResult = User | UserBlockedError | UserDeletedError
type Query {
user(id: String! email:String!): UserResult!
}
}
위와 같이 union type 을 설정하는 방법을 씁니다. 위의 경우 UserResult라는 union을 만들고, query를 날렸을때 user가 있다면 user의 username와 email값을, user가 block되었거나 delete되었다면 우리가 원하는대로 type을 만들고, 그 type에 맞는 형태로 data를 내보내게 됨을 알 수 있습니다.
이렇게 할 경우 서버에서 data result를 보낼 때 type checking이 되고, 각 type을 어떻게 만드느냐에 따라서 delete와 block시 각각의 상황에 맞는 더 상세한 error 메세지의 전달이 가능하게 됩니다.
그 error들을 client입장에서 render시킬 때에도 우리가 예상하고, 원하는 data type으로 들어오므로 미리 맞춘 세팅으로 최대한 UI의 integrity를 해치지 않으며 user에게 보여줄 수 있습니다. 가령 위의 query문의 경우 deleted 일때는 reason만 띄워주고, blocked일 때는 user를 block시킨 사람의 사진과 message를 보여주도록 UI를 미리 구성해 놓을 수 있습니다.
결과적으로 이 방법을 사용시 처음 data schema를 정의할 것은 늘어나지만 기존 apollo client error의 단점을 보완해줄 수 있습니다.
4. 회고
이번 프로젝트를 하며 단순히 만드는데만 급급하여 error handling에 대해서는 많이 신경쓰지 못했습니다. 단순 토이 프로젝트가 아니라 실제 유저들이 사용하고 규모가 있는 product를 만든다면 error와 관련하여 더 다양한 상황이 벌어질 수 있으므로, product을 더 촘촘히 만들고 UX를 향상시키는데에 이런 error handling의 중요성이 더 커질것으로 생각되는데 다음에 다시 apollo client를 이용시에는 처음 data schema를 짤 때부터 백엔드와 프론트엔드가 긴밀히 협력하여 프로젝트 설계를 해보고 싶다는 생각이 듭니다.