본문 바로가기
graphql

[GraphQL] null propagation

by 박연호의 개발 블로그 2022. 10. 1.

GraphQL type field는 기본적으로 nullable이다.

null값에 대해 허용하는 type languaged의 경우 대부분 기본적으로 non-null합니다. 변수를 선언하면 기본적으로 null을 허용하지 않으며 null값을 허용하는 경우 명시적으로 표시해줘야 합니다. 

- typescript
type User = {
  name: string // non-null
  hobby?: string // nullable
}

- c#
Nullable<int> num;
int? num;

- kotlin
var a : String? = null

이와는 반대로 GraphQL field는 기본적으로 nullable합니다. 때문에 반드시 값이 존재하는 경우 명시적으로 non-null을 의미하는 (!)를 표시해줘야 합니다.

 type Profile {
    id: Int!
    url: String!
  }

  type User {
    id: Int!
    name: String
    profile: Profile!
    posts: [Post]
  }

  type Post {
    id: Int!
    content: String
    author: User!
    authorId: Int!
  }

  type Query {
    user: User!
  }

 

user Query는 반드시 User를 반환해야 합니다. 그리고User는 profile의 값이 null이면 안되며, Post.author, Profile.url... 등 !은 non-null field를 의미합니다. 

 

이렇게 GraphQL이 default nullable한 방법을 선택한 이유는 resolver 함수에서 DB 또는 네트워크 I/O 작업이 실패할 수 있기 때문입니다. 이 경우 에러를 던지게 되고, 해당 resolver는 null을 반환하게 됩니다. 즉 resolver 함수내에서 일말의 에러가능성이 존재하기 때문에 아예 기본으로 nullable하도록 하고 있습니다.

 

이러한 이유로 Product Ready GraphQL에서는 type을 반환하는 field의 경우 항상 nullable해야 된다고 말하고 있습니다.

Fields that return object types that are backed by database associations, network calls, or anything else that could potentially fail one day should almost always be nullable.


non-null field에서 null을 반환하면?

위에서 field를 non-null하게 선언했다 하더라도, runtime에선 언제든지 null일 수 있는 상황이 있기 때문에 GraphQL에서는 기본적으로 nullable입니다.

 

그렇다면 만약 non-null field에서 null을 반환하면 어떻게 될까요 ?

  type Profile {
    id: Int!
    url: String   -> return null
  }

  type User {
    id: Int!
    name: String
    profile: Profile
  }

  type Query {
    user: User!
  }
query{
  user {
    name
    profile {
      url
    }
  }
}

클라이언트가 질의함에 따라 field resolver가 실행합니다. Profile.url field에서 null을 반환하고 있지만, nullable로 선언했기 때문에 문제 없습니다.

{
  "data": {
    "user": {
      "name": "user1",
      "profile": {
        "url": null
      }
    }
  }
}

 

이번엔 Profile.url을 non-null로 변경하겠습니다.

  type Profile {
    id: Int!
    url: String!  -> return null
  }

  type User {
    id: Int!
    name: String
    profile: Profile
  }

  type Query {
    user: User!
  }
query{
  user {
    name
    profile {
      url
    }
  }
}
{
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Profile.url.",
      "locations": [
        {
         .....
  ],
  "data": {
    "user": {
      "name": "user1",
      "profile": null
    }
  }
}

여기서 재밌는 부분은 non-null field에 null이 발생했을 때 이 null을 어떻게 처리하냐나는 것입니다. null이 있으면 안되는 곳에 null이 있으니 어떻게든 처리해야 합니다. 물론 Profile.url에서 말구요.

 

GraphQL는 이때 null을 자신의 부모에 던져버립니다. url의 부모는 Profile이니, Profile이 null을 받습니다. Profile은 User.profile로 존재하고 User는 profile을 nullable로 선언했기 때문에 null을 받습니다. 이제 폭탄돌리기가 끝났네요. 

 

나는 non-null field인데 null이 발생했네...? 나는 null을 처리못해, 그러니깐 내 부모한테 던지자. 부모가 알아서 해주겠지...

 

이렇게 null이 발생한 field가 null을 수용할 수 없으면 상위 필드로 전파되며, 이 과정은 nullable field를 찾을 때 까지 반복합니다.

graphql spec - Erros and Non-Nullability

 

null propagation이 진행되다가 nullable field를 만나면 해당 field가 null처리되고, 클라이언트에서 질의한 같은 레벨의 field는 데이터가 그대로 유지가 됩니다.

 

위 예제에서 url에서 발생한 null로 인해 user.profile이 null이 되버렸습니다. 하지만 user.name은 문제가 없습니다. 반대로, 아래처럼 질의한다면 profile.id field는 데이터가 있다 하더라도 null propagation때문에 user.profile이 아예 null 처리가 되어버려 값을 가져오지 못하게 됩니다.

query{
  user {
    name
    profile {
      id     -> 값이 있어도, null propagation때문에 값을 못 가져옴
      url    
    }
  }
}

 

지금은 user.profile에서 null을 수용하였지만, 아무도 수용하지 못하게 되면 클라이언트에서는 null 데이터를 받게 됩니다. 

{
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Profile.url.",
      "locations": [
        {
         .....
  ],
  "data": null
}

논외지만, non-null로 했을 시 위와 같은 문제도 있지만 nullable로 변경하기가 까다롭습니다. 왜냐하면 클라이언트에서 code-generator로 항상 값이 있다고 전제하고 코드를 작성했는데, 갑자기 nullable하게 변경하면 type safe하지 못하기 때문입니다.

 

반대로 nullable에서 non-null로 변경하는 경우 더 안전하며 breaking change가 없습니다.

 

이럴바에야 그냥 field를 모두 nullable로 하는게 나을 것 같기도 합니다.  위의 Product Ready GraphQL 에서도 I/O 작업을 하는 field의 경우 nullable하게 만들어야 한다고 하며, 관련 블로그를 봐도 nullable로 하는 것이 좋다고 합니다(여러 외국 블로그 자료를 보면서 든 주관적인 생각). 

 

하지만 SDL상에서 개념적으로 non-null한 게 맞지만, 오류때문에 nullable하게 하는 것은 less express합니다. 즉 User는 프로필이 무조건 있다 라고 정의를 했지만 실제로 유저의 프로필을 조회하는데 문제가 생기기 때문에 없을 수도 있다 라고 하는 것이 맞지 않다는 것이죠.

 

실제로 Relay에는 타입을 생성할 때 non-nullable하게 할 수 있으며, 만약 null값이 온다면 어떤식으로 처리할 지 핸들링할 수 있는 방법이 있습니다.

 

아직 best practice를 찾고 있지만, 개인적인 견해로는 scalar type(user.name)을 반환하는 field가 아닌 type(user.profile)을 반환하는 field는 nullable로 하는게 괜찮다고 생각합니다. express한 장점을 주고 안정성을 가져오는 거죠. 

 

GraphQL은 rest api처럼 한번에 데이터를 다 만들어서 보내주는게 아니라, 각각의 resolver에서 어떠한 작업을 처리하다 보니 이런 문제도 생기는 것 같습니다.