03 · Interface · Union · Enum
이 문서가 답하는 질문: GraphQL은 다형성을 어떻게 표현하고,
interface·union·enum은 언제 어떻게 다르며,__typename은 왜 필요한가? 한 줄 답 (Pyramid Top): “interface는 공통 필드를 강제하고,union은 공통 없이 선택지를 묶고,enum은 닫힌 값 집합을 강제한다 — 셋 다__typename으로 구체 타입을 가른다.”
Why — 왜 추상 타입이 필요한가
object type만으로 모델링 불가능한 두 종류의 모양이 있다:
- “여러 종류가 같은 자리에 올 수 있다” — 검색 결과는
User일 수도,Post일 수도,Tag일 수도. - “모두 같은 필드를 가지지만 구체 타입이 다르다” —
Notification이MessageNotification이거나FollowNotification인데, 둘 다id·createdAt·isRead는 공통.
또 값 자체가 제한된 집합인 경우도 있다 — role은 ADMIN/EDITOR/READER 중 하나여야지 임의 문자열이면 안 된다.
이 세 가지를 각각 interface · union · enum이 푼다.
How — 세 도구의 사용법
1) interface — 공통 계약
interface Node {
id: ID!
}
interface Notification {
id: ID!
createdAt: String!
isRead: Boolean!
}
type MessageNotification implements Notification & Node {
id: ID!
createdAt: String!
isRead: Boolean!
message: String!
sender: User!
}
type FollowNotification implements Notification & Node {
id: ID!
createdAt: String!
isRead: Boolean!
follower: User!
}interface Notification은 3개의 필드를 강제한다.implements Notification & Node는 여러 interface를 동시에 구현(spec October 2018부터).- 구현 type은 interface의 모든 필드 + 추가 필드를 가진다.
Query에서 사용:
type Query {
notifications: [Notification!]!
}클라이언트는:
query {
notifications {
id # 공통 필드 — 그대로 사용
createdAt
isRead
__typename # 구체 타입 식별
... on MessageNotification { message sender { name } }
... on FollowNotification { follower { name } }
}
}... on MessageNotification은 inline fragment. *“이 객체가 MessageNotification일 때만 이 필드들을 가져와라”*는 뜻.
2) union — 공통 없는 선택지
union SearchResult = User | Post | Tag
type Query {
search(q: String!): [SearchResult!]!
}union은 공통 필드를 강제하지 않는다.- 따라서 클라이언트는 공통 필드를 직접 요청할 수 없다 — 반드시 inline fragment로 구체 타입별 분기.
query {
search(q: "graphql") {
__typename
... on User { id name }
... on Post { id title }
... on Tag { id label }
}
}만약 id를 공통으로 가져오고 싶다면 interface로 모델링해야 한다(Node를 implements).
3) enum — 닫힌 값 집합
enum Role {
ADMIN
EDITOR
READER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type User {
role: Role!
}- enum value는 대문자 + underscore 관례 (스펙 강제는 아님).
- wire 상 문자열로 직렬화되지만 클라이언트와 서버 모두에서 값 검증된다.
- 서버가 모르는 값을 받으면 validation 단계에서 거절.
vs String!: String!이면 "admin"·"Admin"·"superadmin"이 모두 통과. enum은 3개 값 외에는 스키마 레벨에서 reject.
What — 구체 사양
__typename — 메타필드
모든 composite type은 자동으로 __typename: String! field를 가진다. 스키마에 적지 않아도 항상 query 가능.
query { me { __typename id name } }
# 응답: { "data": { "me": { "__typename": "User", "id": "1", "name": "Lee" } } }왜 필요한가: 클라이언트가 구체 타입을 알아야 다음 동작을 결정할 수 있는 경우가 있다.
- Apollo Client/Relay의 정규화 캐시는
__typename + id를 전역 캐시 key로 사용 → 없으면 캐시 동작 안 함. - interface/union 응답에서 어느 구체 타입인지 판별하려면
__typename이 유일한 방법.
대부분의 클라이언트 라이브러리는 모든 selection set에 __typename을 자동 주입한다.
Inline fragment vs Named fragment
Inline fragment — 한 selection set 안에서만 쓰는 분기:
{
notifications {
... on MessageNotification { message }
}
}Named fragment — 재사용 가능한 단위:
fragment NotificationFields on Notification {
id
createdAt
isRead
}
fragment MsgFields on MessageNotification {
message
sender { name }
}
query {
notifications {
...NotificationFields
... on MessageNotification {
...MsgFields
}
}
}Named fragment는 type condition(on Notification)이 필수. spread 위치의 타입과 호환되어야 한다.
Enum description & deprecation
enum PostStatus {
"초안 — 작성자만 볼 수 있음"
DRAFT
"발행됨 — 모두 볼 수 있음"
PUBLISHED
ARCHIVED @deprecated(reason: "use status filter with isArchived")
}전체 예제 — 알림 시스템
interface Node {
id: ID!
}
interface Notification {
id: ID!
createdAt: String!
isRead: Boolean!
}
enum NotificationCategory {
SOCIAL
SYSTEM
BILLING
}
type MessageNotification implements Notification & Node {
id: ID!
createdAt: String!
isRead: Boolean!
category: NotificationCategory!
message: String!
sender: User!
}
type FollowNotification implements Notification & Node {
id: ID!
createdAt: String!
isRead: Boolean!
category: NotificationCategory!
follower: User!
}
type BillingAlert implements Notification & Node {
id: ID!
createdAt: String!
isRead: Boolean!
category: NotificationCategory!
amount: String!
dueDate: String!
}
union SearchHit = User | Post | Tag
type Query {
notifications(unreadOnly: Boolean = false): [Notification!]!
search(q: String!): [SearchHit!]!
}What-if — 자주 틀리는 패턴
함정 1) Interface로 모델해야 할 걸 Union으로
union Notification = MessageNotification | FollowNotification
# 클라이언트는 매번 분기해야 한다
{
notifications {
... on MessageNotification { id createdAt isRead }
... on FollowNotification { id createdAt isRead } # 중복
}
}원칙: 공통 필드가 있고 의미가 같으면 interface, 공통 필드가 없거나 의미가 다르면 union.
함정 2) Union으로 모델해야 할 걸 Interface로
interface SearchHit {
id: ID! # ⚠️ User·Post·Tag가 정말 같은 'id' 의미를 가지나?
}User.id와 Tag.id는 전혀 다른 식별 공간이다. 공통 필드인 것처럼 묶는 것은 오해의 원인. 차라리 union.
함정 3) Enum 대신 String
type Post {
status: String! # ❌
}어느 값이 valid한지 스키마에 안 적혀 있어 클라이언트가 문서를 따로 읽어야 한다. 또 서버는 잘못된 값을 받아도 validation에서 못 잡는다.
함정 4) __typename을 빼고 union 결과 처리
query {
search(q: "x") {
... on User { name }
... on Post { title }
}
}응답을 받았을 때 어느 타입인지 알 방법이 없다:
{ "data": { "search": [{ "name": "Lee" }, { "title": "Hi" }] } }클라이언트는 name이 있으면 User, title이 있으면 Post… property sniffing에 의존하게 된다. 항상 __typename을 요청해라.
함정 5) Enum value 추가 = breaking change?
Enum에 새 값을 추가하는 것은 기술적으로는 backward compatible이지만, 클라이언트 switch 문에서 default 분기가 없으면 깨진다:
switch (post.status) {
case "DRAFT": return ...
case "PUBLISHED": return ...
case "ARCHIVED": return ...
// 새로 추가된 'SCHEDULED'는 어디에도 매칭 안 됨
}원칙: enum value 추가는 서버에선 안전하지만 클라이언트 switch는 default 분기 필수. Relay의 %future added value는 이 문제를 위한 기능.
함정 6) Interface 구현 누락
interface Node {
id: ID!
}
type User implements Node {
# id 빼먹음 ❌
name: String!
}스키마 등록 단계에서 fail. interface가 강제하는 모든 필드는 반드시 구현되어야 한다.
또한 nullable 호환성도 검증된다. interface가 id: ID!라면 구현체는 id: ID!여야 한다 — id: ID(nullable)는 interface 계약 위반.
Insight — 흥미로운 이야기
“
__typename은 type discriminator의 GraphQL판이다”TypeScript의 discriminated union은
kind: "user"같은 수동 필드에 의존한다. GraphQL은 그것을 언어 차원에서 자동화했다 — 모든 composite type에__typename이 암묵적으로 존재. 그 결과 클라이언트는 서버가 의도적으로 노출한 필드 없이도 구체 타입을 알 수 있다. 이게 Apollo·Relay의 정규화 캐시가 GraphQL에서만 가능한 이유다 — REST는 응답 JSON에 어떤 타입인지 정보가 없어 캐시 key를 만들 방법이 없다.
“Relay의 Node interface는 사실상 표준이다”
Relay(2015)는 모든 식별 가능한 객체가
Nodeinterface를 implement해야 한다는 관례를 만들었다:interface Node { id: ID! } type Query { node(id: ID!): Node }이 한 줄로 임의의 객체를 ID로 refetch할 수 있게 된다. 캐시 무효화·optimistic update·페이지네이션이 모두 이 위에 쌓인다. Apollo도 공식 spec은 아니지만
Node패턴을 권장한다. GitHub·Shopify·Airbnb의 공개 스키마는 모두Node를 채택했다.
“Enum이 String이 아닌 진짜 이유는 introspection”
클라이언트 코드 생성 도구(GraphQL Code Generator·Apollo CLI)는 enum을 TS union literal이나 Java enum으로 자동 생성한다 — 가능한 이유는 enum이 introspection으로 모든 값을 보여주기 때문.
String!이면 어떤 값이 valid한지를 코드 생성기가 알 방법이 없다. 즉 enum은 런타임 검증 + 컴파일타임 타입 생성 둘 다를 가능하게 하는 작은 도구다.
요약 + Mermaid
요약: 공통 필드 강제는
interface, 공통 없는 선택지는union, 닫힌 값 집합은enum.__typename은 모든 composite type에 자동으로 있는 메타필드이고, interface/union 분기와 정규화 캐시가 모두 이 위에 쌓인다. 다음 문서(04-input-types-and-arguments)는 데이터를 넣는 쪽 — input type을 다룬다.