'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를 만족하지 않는 타입에 대해서 딱히 에러를 내 주지 않았기 때문이다.