[번역] GraphQL 에러 처리 가이드

Marc-Andre Giroux - 2020년 8월 1일


🌱 이 포스트는 계속 작성 중이며, 베스트 프랙티스가 발전함에 따라 변경될 가능성이 높습니다 🌳
GraphQL 에러는 우리 중 많은 이들이 어려워하는 부분입니다. 좋은 사례들이 나타나고는 있지만, 커뮤니티에서 아직 하나의 컨벤션으로 정착되지는 않았습니다. 이 가이드가 여러분의 GraphQL 서버에서 에러를 구조화할 수 있는 다양한 방법들과 각 방법의 트레이드오프(Tradeoff)를 명확히 이해하는 데 도움이 되기를 바랍니다.

1단계: GraphQL 에러 (일명 최상위 에러)

GraphQL 명세(Specification)는 에러에 대해 꽤 잘 설명하고 있는데, 왜 처음에 혼란스러운 걸까요?
{
"data": {
"createProduct": null
},
"errors": [
{
"path": [
"createProduct"
],
"locations": [
{
"line": 2,
"column": 3
}
],
"message": "Could not resolve to a node with the global id of 'shop-that-does-not-exist'"
}
]
}

❗️단점: 스키마의 부재

우리는 GraphQL의 타입 시스템을 좋아합니다. 하지만 흔히 "최상위(Top-Level)" 에러라고 불리는 GraphQL 응답의 errors 키에는 이러한 스키마가 없습니다. 명세에는 정의되어 있지만, 인트로스펙션(Introspection)을 통해서는 클라이언트가 그 안에 무엇이 들어있는지 알 수 없으며, 발전시키거나 확장하기도 어렵습니다. extensions 키를 통해 제공자가 추가 필드를 더할 수 있지만, 이는 명세를 벗어나는 일이며 스키마 외부에서 별도로 문서화해야 합니다. 이는 이상적이지 않습니다.

❗️단점: Null 허용 여부(Nullability)

최상위 에러의 또 다른 잠재적 문제는 에러가 발생했을 때 해당 필드가 null이어야 한다는 점입니다. 뮤테이션(Mutation)의 일부로 에러를 반환받고 싶으면서도 결과 데이터에 대해 쿼리하고 싶은 경우, 이는 치명적인 문제가 될 수 있습니다. 흔한 예로, 에러가 발생한 뮤테이션 이후에 리소스의 실제 상태를 서버가 다시 보내주는 경우가 있습니다. 최상위 에러를 사용하면 뮤테이션 필드 전체가 null이 되어야 하므로 이를 수행할 수 없습니다!

❗️단점: 예외적인 상황 전용

오늘날 "최상위" 에러는 일반적으로 "예외적인" 에러와 개발자용 에러를 나타내는 방식으로 받아들여집니다. [Lee Byron]은 GraphQL-Spec 저장소의 몇몇 댓글을 통해 이 점을 분명히 했습니다.
GraphQL 에러는 서비스 중단이나 기타 내부 장애와 같은 예외적인 시나리오를 인코딩합니다. API 도메인의 일부인 에러는 해당 도메인 내에서 캡처되어야 합니다.
일반적인 철학은 에러를 예외적인 것으로 간주하는 것입니다. 사용자 데이터가 에러로 표현되어서는 안 됩니다. 사용자가 부정적인 안내를 받아야 하는 행동을 한다면, 그 정보는 에러가 아닌 데이터로서 GraphQL에 표현해야 합니다. 에러는 항상 개발자 실수나 예외적인 상황(예: 데이터베이스 오프라인)을 나타내야 합니다.
확장(extensions)을 추가하여 최종 사용자 에러를 처리하기 좋게 만들 수는 있지만, 이러한 에러에 스키마가 없고 데이터와 함께 배치될 수 없는 경우가 많다는 사실 때문에 이러한 유스케이스에는 이상적이지 않은 선택이 됩니다.

✅장점: 개발자 에러에 적합

서비스 사용 불가? 구문 에러? 속도 제한(Rate limited)? 타임아웃? 이러한 유형의 에러는 "최상위" 에러에 완벽하며 반드시 사용해야 합니다. 클라이언트가 더 나은 방식으로 처리할 수 있도록 에러 코드를 추가하여 개선하세요. 언젠가는 서로 다른 API 간의 일관성과 더 나은 클라이언트를 위해 공통 에러 코드가 추출될 수 있기를 바랍니다.

✅장점: 우리가 가진 가장 가까운 컨벤션!

이러한 에러는 GraphQL 명세에 정의되어 있으므로 확실한 장점입니다. 클라이언트는 이미 이를 처리하는 방법을 알고 있으며, 서로 다른 API 간에 일관성을 가질 수 있다는 점은 훌륭합니다.

2단계: 임시(Ad Hoc) 에러 필드

최상위 에러가 사용자용 에러에 이상적이지 않다면, 이를 스키마에 직접 노출할 방법을 고민해야 합니다. 가장 간단한 방법은 뮤테이션 페이로드(Payload)에 필드를 추가하는 것입니다. 예시는 다음과 같습니다.
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload
}

# 💡 "MutationPayload" 래퍼 타입은 처음에
# GraphQL 클라이언트인 Relay에 의해 지정된 일반적인 컨벤션입니다.
type CreateUserPayload {
user: User
userNameWasTaken: Boolean!
userNameAlternative: String!
}
이 예시에서는 에러 처리를 돕기 위해 뮤테이션 페이로드 타입에 두 개의 필드를 추가했습니다. createUser는 생성하려는 사용자 이름이 이미 존재할 때 에러가 발생할 수 있습니다. 이 경우 에러를 표시하고 입력한 내용을 바탕으로 더 나은 사용자 이름을 제안하고 싶을 것입니다.

✅장점: 발견 가능성(Discoverability)

이 뮤테이션이 무엇을 반환할 수 있는지, 어떤 종류의 에러가 발생할 수 있는지 확인하기가 매우 쉽습니다. 만약 이를 최상위 에러로 처리해야 했다면, createUser 뮤테이션의 경우에만 에러에 userNameAlternative 확장을 추가해야 했을 것입니다. 클라이언트가 이를 알아내고 사용하기란 꽤 어렵습니다.

✅장점: 단순함

이 솔루션은 사용하기에도 가장 간단합니다. 클라이언트가 에러로부터 무엇을 필요로 하는지 살펴보고, 이를 스키마에 직접 추가하면 됩니다. 또한 뮤테이션이 유스케이스를 충족하지 못할 때 클라이언트에게 진정으로 필요한 것이 무엇인지 클라이언트 관점에서 에러를 생각하게 만듭니다.

❗️단점: 불가능한 상태(Impossible States)

뮤테이션 페이로드에 에러 필드를 그냥 추가할 때 발생하는 한 가지 짜증나는 점은 이론적으로 불가능한 상태를 허용한다는 것입니다. 위의 예시에서 user 필드를 nullable로 만들었는데, 사용자 이름이 중복되면 user는 null이 되고 userNameWasTaken은 true가 되기 때문입니다.
우리는 이를 직관적으로 알 수 있지만, 스키마는 이를 실제로 알려주지 않습니다. 이론적으로 userNameWasTaken이 true이면서 user 데이터도 존재할 수 있습니다. 이것이 무엇을 의미할까요? 불분명합니다.
이러한 동작은 필드에 대한 문서나 설명에 기술되어야 하는데, 애초에 스키마가 있는 상황에서 이는 결코 이상적이지 않습니다!

❗️단점: 일관성

이 접근 방식의 또 다른 단점은 뮤테이션 에러 전체를 표준화하지 않는다는 것입니다. 모든 뮤테이션은 무엇이 잘못될 수 있는지 설명하기 위한 일련의 임시 필드들을 갖게 되며, 이는 제네릭(Generic) 클라이언트를 구축하거나 사람이 에러를 찾는 것을 더 번거롭게 만들 수 있습니다.

3단계: 에러 배열(Error Array)

임시 에러 필드만으로 부족할 때, 우리는 보통 좀 더 구조화된 것을 찾기 시작합니다. 그 방법 중 하나는 에러 타입을 만들기 시작하는 것입니다!
type CreateUserPayload {
user: User
userErrors: [UserError!]!
}

type UserError {
# 에러에 대한 설명
message: String!
# 에러를 유발한 입력 값에 대한 경로
path: [String!]
}



UserError.path 필드에 대한 짧은 메모

위의 예시에서 UserError 타입에 path 필드가 있습니다. 이 필드는 에러를 유발한 입력 값(있는 경우)을 가리키는 문자열 리스트입니다.
예를 들어, 이미 사용 중인 username으로 사용자를 생성하려고 한다고 가정해 보겠습니다.
mutation {
createUser(input: { username: "xuorig" }) {
user
userErrors {
message
path
}
}
}
다음과 같은 응답을 받게 됩니다.
{
"data": {
"createUser": {
"user": null,
"userErrors": [{
"message": "Username `xuorig` was already taken.",
"path": ["input", "username"]
}]
}
}
}
path가 우리가 사용한 username 입력 필드를 어떻게 가리키는지 주목하세요. 이는 클라이언트가 에러를 일으킨 폼(Form) 필드를 가리키는 등의 처리를 할 때 매우 유용합니다. 자, 다시 에러 배열 이야기로 돌아가겠습니다.


이것은 제가 접한 에러를 구조화하는 초기 방법 중 하나로, 예전 Shopify에서 사용했던 방식입니다 👴 (그 이후 그들의 방식은 발전했습니다).

✅장점: 일관성

이제 에러에 대한 공통 계약(Contract)을 갖게 되었으며, 이는 훌륭합니다. 클라이언트는 뮤테이션에서 발생하는 에러가 messagepath를 모두 가질 것임을 알게 되며, 우리는 원하는 대로 다른 메타데이터를 추가하여 확장할 수 있습니다.

✅장점: 발전 가능성

에러가 이제 리스트 타입의 일부이고 공통 UserError 타입을 가지므로, 클라이언트는 좀 더 미래 지향적인 방식으로 에러를 처리할 수 있습니다. 서버가 새로운 가능한 에러를 추가할 때, 에러 리스트를 처리하는 클라이언트는 별도의 작업 없이도 이를 받아볼 수 있습니다. 임시 필드를 사용하면 클라이언트는 새로운 케이스를 처리하기 위해 종종 새로운 커스텀 로직을 구현해야 합니다.

❗️단점: 불가능한 상태

임시 에러 필드와 마찬가지로, 우리 스키마는 여전히 응답에 user가 있으면서 userErrors 리스트에도 항목이 존재하는 것과 같은 잠재적으로 불가능한 상태를 허용합니다.

❗️단점: 사용자가 선택해야 함

이 단점은 이 글의 많은 솔루션에 공통적으로 해당되지만, 여기서 소개하기로 했습니다. "스키마 내 에러" 접근 방식의 한 가지 단점은 클라이언트가 에러 필드를 선택하지 않음으로써 에러를 완전히 무시하기가 매우 쉽다는 것입니다.


여담으로, 제가 고민해 본 아이디어 중 하나는 @mustSelect 스키마 지시어(Directive) / 에러 같은 것입니다. 클라이언트가 해당 필드를 선택하지 않으면 에러를 생성하도록 특정 필드에 주석을 달 수 있습니다.
type CreateUserPayload {
userErrors: [UserError!]! @mustSelect
}
클라이언트가 정말로 해당 필드에 관심이 없다면 명시적으로 거부해야 합니다.
mutation {
createUser(input: {}) @ignoreSelection(fields: ["userErrors"]) {
user
}
}
이 방식의 멋진 점은 userErrors 필드를 응답에 강제로 포함시키지 않아도 된다는 것입니다. 강제로 포함시키면 GraphQL의 선언적 특성을 해칠 수 있기 때문입니다. 대신 사용자가 개발 시점에 에러를 발견하여 명시적으로 거부하거나 필드를 선택하도록 유도할 수 있습니다.



❗️단점: 커스텀 에러 필드

UserError 타입이 하나뿐이므로 특정 에러에만 특화된 필드를 지원하기가 어렵습니다. 예를 들어, 제네릭한 UserError 타입에 userNameSuggestion 필드를 추가하는 것은 별로 의미가 없습니다.

4단계: 에러 인터페이스(Error Interface)

사용자 에러 리스트의 자연스러운 진화는 UserError를 인터페이스 타입으로 만들고, 필요할 때 구체적인 구현체를 만드는 것입니다.
type CreateUserPayload {
user: User
userErrors: [UserError!]!
}

type UserNameTakenError implements UserError {
userNameSuggestion: String
message: String!
path: [String!]
}

type SomeOtherError implements UserError {}

interface UserError {
# 에러에 대한 설명
message: String!
# 에러를 유발한 입력 값에 대한 경로
path: [String!]
}

✅장점: 발전 가능성

이전의 단순 리스트 타입 솔루션과 마찬가지로, 이 구조 역시 시간이 지남에 따라 발전시키기에 매우 좋습니다. 이번에는 에러에 대한 공통 인터페이스가 있으므로 클라이언트는 에러 간의 공통 계약을 알 수 있고, 필요할 때 새롭고 더 구체적인 에러 타입을 만들 수도 있습니다.
mutation {
createUser(input: {}) {
user { id }
userErrors {
message
path
... on UserNameTakenError {
userNameSuggestion
}
}
}
}

❗️단점: 어떤 에러가 발생할지 알기 어려움

여전히 작은 문제가 있습니다. 클라이언트가 우리 API와 연동할 때, 작업을 실행할 때 발생할 수 있는 에러 세트를 알기가 꽤 어렵습니다. 모든 에러가 공통 계약을 따른다는 것은 알지만, 스키마는 이 특정 뮤테이션이 어떤 구체적인 타입들을 반환할 수 있는지 정확히 알려주지 않습니다.
이를 해결하는 몇 가지 방법이 있습니다:
  1. A) 좀 더 제네릭하게 유지하고 대신 UserCreationError 리스트를 가집니다.
  1. B) 더 구체적인 UserCreation 인터페이스와 UserError 인터페이스를 함께 가집니다.
  1. C) 대신 유니온(Union) 타입을 살펴봅니다.

5단계: 결과 타입(Result Types)

GraphQL에서 에러를 표현하는 또 다른 인기 있는 접근 방식은 유니온 타입을 사용하는 것입니다. Sasha Solomon의 훌륭한 포스트에서 그 뒤에 담긴 철학을 더 자세히 설명하고 있습니다.
에러 유니온은 스키마를 구조화하는 매우 표현력 있는 방법이기 때문에 훌륭합니다. 클라이언트가 리소스를 쿼리하거나 뮤테이션할 때 어떤 일이 일어날 수 있는지 즉시 확인할 수 있게 해줍니다.
type Mutation {
createUser(input: CreateUserInput): CreateUserResult
}

union CreateUserResult = UserCreated | UserNameTaken

type UserCreated {
user: User!
}

type UserNameTaken {
message: String!
suggestion: String!
}

✅장점: 불가능한 상태의 제거

이와 같은 유니온 결과 타입을 사용하면 앞서 언급한 "불가능한 상태를 불가능하게 만들기" 문제를 해결할 수 있습니다. 이제 적절한 컨텍스트에서 적절한 타입을 얻게 되므로 모든 필드를 non-null로 만들 수 있음에 주목하세요!

✅장점: 쿼리 측면에서 훌륭함

이 접근 방식은 쿼리 측면에서도 훌륭하게 작동합니다. 실제로 Sasha의 기사에 나온 예시는 User 타입에 관한 것이며 쿼리 예시들입니다.

✅장점: 발견 가능성

스키마를 보는 것만으로 우리가 얻을 수 있는 가능한 결과들을 빠르게 알 수 있습니다. 이는 문서화 측면에서도 정말 훌륭할 수 있습니다.

🟡경고: 구현이 잠재적으로 어려울 수 있음

이는 구현 방식에 따라 다르지만, 제 경험상 런타임에 어떤 종류의 에러가 발생할지 미리 정적으로 아는 것이 어려울 수 있습니다. 다만 이는 그 부분을 개선할 수 있는 좋은 기회가 될 수도 있습니다!

❗️단점: 발전시키기 어려움

이러한 직접적인 유니온 결과 타입을 사용하면 새로운 유형의 에러로 스키마를 발전시키기가 조금 더 어렵습니다. 클라이언트가 다음과 같은 방식으로 스키마를 쿼리하고 있다고 상상해 보세요.
mutation {
createUser(input: {}) {
... on UserCreated {
user { id }
}
... on UserNameTaken {
message
}
}
}
만약 비밀번호가 너무 짧다는 등의 새로운 에러 타입이 추가된다면 어떻게 될까요? 유니온 타입을 다루고 있기 때문에 클라이언트는 이러한 변경에 미리 대응할 방법이 없습니다.

❗️단점: 다중 에러

이 특정 유니온 타입 결과 구현은 하나 이상의 에러 시나리오를 반환하는 것을 실제로 허용하지 않습니다. 예를 들어 사용자 이름이 중복되었다는 것과 비밀번호가 너무 짧다는 것을 동시에 보여주고 싶다면 스키마를 다른 방식으로 설계해야 합니다.
  1. A) 여러 하위 에러를 담을 수 있는 더 제네릭한 UserCreationError를 가집니다.
  1. B) 유니온을 사용하는 userErrors 리스트 타입을 선택합니다.

6단계: 에러 유니온 리스트(Error Union List)

유니온을 사용하여 userErrors 리스트를 개선할 수도 있습니다.
type CreateUserPayload {
user: User
userErrors: [CreateUserError!]!
}

union CreateUserError = UserNameTaken | PasswordTooShort | MustBeOver18

type UserNameTaken {
message: String!
suggestion: String!
}

✅장점: 표현력 있고 발견 가능한 스키마

유니온을 통해 사용자를 생성할 때 무엇이 잘못될 수 있는지 쉽게 알 수 있습니다.

✅장점: 다중 에러 지원

이전 솔루션에서 본 결과 타입과 달리, 유니온의 리스트 타입을 가졌으므로 여러 에러가 있는 폼을 지원할 수 있습니다.

❗️단점: 발전시키기 어려움

이전에 본 에러 결과 타입과 마찬가지로, 클라이언트가 최신 상태를 유지하지 못하는 상황에서 CreateUserError 타입에 새로운 항목을 추가하기가 어렵습니다.

6a단계: 에러 유니온 리스트 + 인터페이스

인터페이스의 확장성과 유니온의 표현력을 결합할 수 있다면 어떨까요? 가능합니다!
type CreateUserPayload {
user: User
userErrors: [CreateUserError!]!
}

union CreateUserError = UserNameTaken | PasswordTooShort | MustBeOver18

type UserNameTaken implements UserError {
message: String!
path: String!
suggestion: String!
}

interface UserError {
message: String!
path: String!
}
이 솔루션이 작동하려면 모든 유니온 멤버가 공통 인터페이스를 구현해야 합니다. 이를 보장하기 위해 스키마 린터(Linter)를 찾아보시는 것을 권장합니다. graphql-schema-linter가 이 용도로 훌륭합니다.

✅장점: 표현력 있고 발견 가능한 스키마

✅장점: 다중 에러 지원

✅장점: 더 쉬운 발전

클라이언트는 이제 특정 에러를 선택할 수도 있고, 인터페이스 계약으로 폴백(Fallback)할 수도 있습니다. 즉, 새로운 에러를 절대 놓치지 않게 됩니다!
mutation {
createUser(input: {}) {
user { id }
userErrors {
# 특정 케이스
... on UserNameTaken {
message
path
suggestion
}
# 인터페이스 계약
... on UserError {
message
path
}
}
}
}

❗️단점: 상당히 장황함!

이 솔루션은 움직이는 부분이 많고 스키마에 많은 타입을 추가합니다. 개발자 경험 측면에서, 코드 우선(Code-first) 방식으로 스키마를 구축하고 있다면 이는 훌륭한 추상화 뒤에 숨길 수 있습니다.

최종 단계: 자신에게 가장 적합한 것 고르기

끝까지 오셨군요! 각 단계를 거치면서 에러 스키마의 정확성은 높아졌지만, 장황함과 복잡성도 함께 증가했습니다. 더 많은 것이 항상 더 좋은 것은 아니며, 결국 임시 에러와 완전한 유니온 타입 + 인터페이스 사이의 어딘가를 선택하게 될 가능성이 높습니다. 일반적인 규칙으로, 공개 API의 경우 가능한 한 쉽게 발전할 수 있는 좀 더 구조화된 것을 원할 것이고, 내부 컨텍스트에서 단일 클라이언트가 사용하는 경우에는 오랫동안 좀 더 단순한 방식으로도 충분할 수 있습니다 👍
새로운 솔루션이 나오면 이 포스트를 계속 업데이트하겠습니다. 그동안 즐거운 스키마 구축 되세요!
0
5

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글