Java로 코드를 작성해보신 분이라면 throws에 의해 컴파일 에러가 발생했을 때 뭔지는 잘 모르겠고 IDE가 제시한 대로 catch로 감싸고 넘어간 경험이 누구에게나 있을 것이라 생각합니다. 그만큼 예외 상황은 잘 이해하고 적절히 처리하기는 쉽지 않은 것 같습니다. 오늘은 카카오스타일이 사용하고 있는 GraphQL에서 에러를 어떻게 전달하고 처리하고 있는지 설명하려고 합니다.

GraphQL 에러 형식

GraphQL은 다음과 같은 데이터를 반환합니다.

{
  "data": {
    "root_field": ...
  },
  "errors": [
    { "message": "error message", "locations": [], "path": [] }
  ]
}

GraphQL을 처음 접했을 때는 errors가 배열이라는 것을 이해하지 못했습니다. 그래서 다음과 같이 API를 설계하는 실수를 하기도 합니다.

type Mutation {
  authenticate(input: AuthenticateInput!): AuthenticateResult!
}

type AuthenticateResult {
  success: Boolean!
  user_id: ID
  error_code: String
}

errors가 배열인 이유는 명확합니다. GraphQL은 여러 데이터를 한번에 요청할 수 있는데, 그 중 일부 데이터만 반환하는 것이 가능합니다. 반환에 실패에 한 경우 그 이유(데이터 없음, 권한 없음등)를 반환해야 하는데 그 이유가 여러가지 일 수 있는 것이죠.

현재 카카오스타일은 에러 발생시 errors에 데이터를 채워서 반환합니다. 다만 에러가 여러개인 경우 적절한 처리방법을 알지 못해 첫번째 에러만 유의미하게 처리합니다. API에 따라 null을 반환할 수도 있기 때문에 data 필드가 null인 것을 에러로 처리하지는 않고 errors 배열에 데이터가 하나 이상있으면 에러로 처리합니다. 반대로 errors 배열에 데이터가 있으면 data 필드에 값이 있어도 에러로 처리하고 있습니다.

message 필드와 에러 코드

사용자에게는 사용자 친화적인 에러 메시지를 표시해줘야 합니다. 이 메시지를 어디서 관리해야 하는지도 쟁점입니다.

같은 상황이여도 사용자마다 다른 메시지가 필요할 수도 있고, UI 이슈라고 보면 클라이언트에서 관리하는게 맞을 것 같기도 합니다만, 클라이언트는 수정이 어렵다라는 이슈가 있습니다. (특히 앱인 경우) 또한 에러 상황마다 일일이 분기 처리를 해야 합니다. 테이블로 메시지를 관리할 수도 있습니다만 어떤 에러 메시지는 고정 메시지가 아닐 수도 있습니다. (예, xxx 상품은 구매할 수 없습니다)

이런 이유로 에러 메시지는 서버가 관리하고 있습니다. 클라이언트는 대부분의 경우 서버가 보내주는 에러 메시지를 맥락에 대한 이해없이 그대로 보여주는 식으로 동작합니다. 다만 이 경우 다국어 처리에 대한 고민이 필요합니다. 클라이언트가 에러 메시지를 처리하면 클라이언트 언어를 인식해서 적절한 메시지를 표시할 수 있지만, 서버는 클라이언트에게서 언어 정보를 받아서 요청별로 에러 메시지를 다르게 구성하는 처리를 해야 합니다.

일부 에러 상황은 클라이언트가 특별히 처리를 해야 할 수도 있습니다. (예를 들어 로그인이 안 되어 있다는 에러를 만난 경우 로그인 페이지로 이동) 이런 경우 사용자 친화적인 메시지를 보고 처리하는 것은 무리기 때문에 (특히 언어마다 메시지가 다르다면) 에러 코드도 같이 보내야 합니다.

GraphQL 에러 형식에서 message 필드에는 사용자 친화적인 메시지를 담고 있습니다. 에러 코드에 대한 표준은 없기 때문에 extensions에 담아서 반환해야 합니다. 다음은 이 형식에 따른 에러 메시지 예입니다

{
  "errors": [
    {
      "message": "로그인을 해주세요.",
      "extensions": {
        "code": "auth_not_logged_in"
      }
    }
  ],
  "data": null
}

추가 정보

에러 메시지와 에러 코드만 있어도 대부분은 문제가 없지만 추가 정보가 필요한 경우가 있습니다. 이런 정보는 extensions에 담고 있습니다.

에러가 발생할 경우 로그에는 주어진 에러 코드로 에러를 기록합니다. 에러 코드가 있다고 모두 오류 상황은 아니고 프로세스상 일상적으로 발생할 수 밖에 없는 에러가 있기도 합니다. 사용자에게는 에러를 반환하는 것이 맞지만, 모니터링에서는 에러로 취급하지 않을 에러인 경우 ignorable 란 필드를 true 로 설정하고 있습니다.

어떤 클라이언트/상황에서는 단순히 에러 메시지가 아니라 더 많은 정보를 표시하고 싶을 수도 있습니다. (예를 들어 에러 팝업 타이틀이나, 아이콘, 닫기 버튼 메시지등) 이런 경우를 위해 contents?: { type: string; title: string; message: string; link_title?: string; link_url?: string }; 와 같이 스키마를 정해서 소통하는 API도 있습니다. (전반적으로 적용해도 될 것 같지만 아직 표준화가 덜 됐습니다)

어떤 에러는 부가적인 정보가 필요한 경우가 있습니다. 예를 들어 소셜 로그인시 이미 해당 이메일의 계정이 존재할 수 있습니다. 이메일이 같다고 무조건 로그인을 시키면 안 되고, 대신 사용자에게 이메일이 이미 사용중이니 해당 계정으로 로그인을 시도하도록 유도하기로 했고, 이를 위해 마스킹된 이메일 문자열을 email 필드에 담아서 반환하도록 했습니다.

HTTP 상태 코드

GraphQL 스펙은 전달 방식에 대해 정의하고 있지 않지만 일반적으로는 HTTP 프로토콜을 사용합니다. 이때 HTTP 상태 코드를 사용할지 여부도 쟁점입니다.

처음 GraphQL 서빙을 위해 사용했던 express-graphql는 에러시 500 에러를 반환했습니다. 그 다음으로 사용한 apollo-server-express는 에러 상황에서도 200을 반환해서 약간의 혼란이 있었습니다.

현재 논의가 진행 중인 스펙에서는 2xx이 아닌 에러를 반환하는 것으로 진행되고 있습니다만 (부분 성공의 경우는 200이여야 합니다) 카카오스타일에서는 현재 무조건 200을 반환하는 것으로 정했습니다.

GraphQL 문법에 어긋나는 경우에만 400 에러 등을 반환하고, 내부 리졸버를 정상적으로 수행한 경우에는 200입니다. 비지니스 로직의 실패는 HTTP 상태 코드가 아닌 errors 필드를 보고 판단하게 됩니다.

다만 앞으로 바뀔 여지가 있기에 HTTP 상태 코드를 의존하지 않는 형태로 클라이언트를 작성하고 있습니다.



comments powered by Disqus