Bits: Relay의 __typename 과 타입 아웃풋

Jaeho Lee·2024년 5월 26일
1

'Bits'는 아주 작은 조각들을 의미하는 것으로, 제 블로그에서 큰 단위의 저술보다는 작은 주제를 다뤄보고 싶을 때 사용하는 태그입니다.

Relay의 __typename 동작에 대해 설명해볼까 한다. (v16 기준)

Relay 컴파일러는 GraphQL의 Union을 __typename 필드를 select해줄 경우, tagged union으로 빌드를 해 주는 동작을 가지고 있다. (공식 매뉴얼에는 써져있지 않다) __typename 필드를 specify해주지 않는다면, 필드는 하나의 타입으로 뭉뚱그려져 존재하지 않을 수 있는 필드는 옵셔널로, 모두에게 존재하는 필드는 논 옵셔널로 표현된다.

가장 흔한 예시인 User 스키마와 에러 타입, 쿼리로 설명해보겠다.
(https://relay.dev/compiler-explorer 에서 실제로 나오는 타입을 확인할 수 있다)

__typename 을 적어주지 않았을 경우:

# Schema
interface Error {
  message: String
}

type InternalError implements Error {
  message: String 
}

type AuthError implements Error {
  message: String 
}

type User {
  id: ID!
  name: String!
  age: Int!
}

union UserPayload = User | AuthError | InternalError

type Query {
  user(id: ID!): UserPayload
}

# document
query Test {
  user(id: 1) {
    ... on User {
      id
      name
      age
    }
  }
}

Output:

export type InlineFragmentSpreadQuery$variables = Record<PropertyKey, never>;
export type InlineFragmentSpreadQuery$data = {
  readonly user: {
    readonly age?: number;
    readonly id?: string;
    readonly name?: string;
  } | null | undefined;
};
export type InlineFragmentSpreadQuery = {
  response: InlineFragmentSpreadQuery$data;
  variables: InlineFragmentSpreadQuery$variables;
};

분명히 age, id, name이 옵셔널로 나오면 안 되는데...?! 와 같은 생각을 할 수 있는데, __typename을 써 주지 않아서 그렇다.

아래와 같이 쿼리에 __typename 필드를 추가하면:

query Test {
  user(id: 1) {
    ... on User {
      __typename # 추가된 필드
      id
      name
      age
    }
  }
}

Output:

export type InlineFragmentSpreadQuery$variables = Record<PropertyKey, never>;
export type InlineFragmentSpreadQuery$data = {
  readonly user: {
    readonly __typename: "User";
    readonly age: number;
    readonly id: string;
    readonly name: string;
  } | {
    // This will never be '%other', but we need some
    // value in case none of the concrete values match.
    readonly __typename: "%other";
  } | null | undefined;
};
export type InlineFragmentSpreadQuery = {
  response: InlineFragmentSpreadQuery$data;
  variables: InlineFragmentSpreadQuery$variables;
};

Tagged union으로 변경되어, id, age, name이 non-optional로 제대로 나온 것을 확인할 수 있다. (union의 다른 타입들을 exhaustive하게 모두 적어주지 않았을 경우 '나머지'는%other 이라는 __typename 으로 표시되는데, 이는 실제로 존재하는 값은 아니고 타입 상의 구분을 위한 값이므로 주의해야 한다.)

그러면 다음으로 자연히 생각되는 것은, 유니언의 공통된 필드들을 한번에 써 줄 방법이 없는가이다. (interface를 활용한다든지, 아니면 그냥 바깥쪽에 필드명을 써 준다든지...) 그런데 이 부분에서 조금 아쉬운 점이 드러난다.

비록 자세히 살펴보진 않았지만 (아마 봐도 잘 이해하진 못하겠지만) Relay의 타입 컴파일러가 뭔가 값들을 비교하고 추적해서 최적화를 해 준다거나 등의 path가 존재하지는 않는 듯했고, 이에 대한 테스트들도 존재하지 않았다. [^1]

서비스가 흥해서 User를 다양한 variant로 재구성한다고 해 보자. 감당못할 초대형 브레이킹 체인지

User를 interface로 승격하고, AdminUser, GeneralUser, GuestUser를 그에 속하는 타입으로 표현한다. GuestUser는 k-게시판과 같은 곳에 존재하는 이름만 존재하는 익명 유저라고 해 보자. 그렇다면 그들 사이의 공통점은 name 정도 밖에 없을 것이므로, User 인터페이스는 name 필드만을 가지고 있다. 그러면 스키마는 아래와 같이 표현이 가능할 것이다.

interface Error {
  message: String
}

type InternalError implements Error {
  message: String
}

type AuthError implements Error {
  message: String
}

interface User {
  name: String!
}

type AdminUser implements User {
  id: ID!
  name: String!
  age: Int!
  permissions: [String!]!
}
type AuthUser implements User {
  id: ID!
  name: String!
  age: Int!
  referredUser: User
}

type GuestUser implements User {
  name: String!
}
 
union UserPayload = AdminUser | AuthUser | GuestUser | AuthError | InternalError

type Query {
  user(id: ID!): UserPayload
}

그렇다면 이제 인터페이스를 inline fragment로 select했을 때 공통 필드에 대한 쿼리를 해 보면 결과는 다소 실망스럽다:

query TestQuery {
  user(id: 1) {
    ... on User {
      __typename
      name
    }
    ... on Error {
      __typename
      message
    }
  }
}

Output:

export type TestQuery$variables = Record<PropertyKey, never>;
export type TestQuery$data = {
  readonly user: {
    readonly __typename?: string;
    readonly message?: string | null | undefined;
    readonly name?: string;
  } | null | undefined;
};
export type TestQuery = {
  response: TestQuery$data;
  variables: TestQuery$variables;
};

마치 동작하지 않는 것처럼 표현되고 있다. (웃기는 점은 __typename 까지 optional하다는 것.)

이것이 버그인지, 아니면 시멘틱 측면에서 당연한 것인가 여부는 조금 묘하다는 생각이 들어 일단 비슷한 깃헙 이슈에 댓글만 달아놓은 상태이다. 하지만 이에 대한 트윗을 했을 때 릴레이 팀원 분이 멘션을 달아주셨는데 (모르고 한참 후에 발견함) 느낌 상 의도한 동작이 아닌 쪽에 가깝지 않나 생각은 해 본다.

주석

[^1]: interface에 대한 지원이 (적어도 타입 레벨에서) 제대로 되는지 의심되긴 하는데, 테스트를 해 보던 중 interface를 만족하지 않는 타입에 대해서 딱히 에러를 내 주지 않았기 때문이다.

profile
개발자

0개의 댓글