GraphQL 명세 ③: 스키마를 떠받치는 타입 시스템(Type System)
지난 편에서 우리가 작성하는 쿼리 문서의 문법을 살펴봤는데요. 그런데 쿼리는 혼자서는 아무 의미가 없습니다. user(id: "1") { name }이라는 요청이 말이 되려면, 서버 쪽에 “User라는 타입이 있고, 거기에 name이라는 필드가 있다”는 약속이 미리 정의되어 있어야 하니까요. 그 약속이 바로 스키마(schema) 이고, 스키마를 이루는 재료가 이번 편의 주제인 타입 시스템(Type System) 입니다.
명세 제3장은 GraphQL에서 가장 분량이 많고 중요한 장입니다. 서버가 어떤 타입들을 조합해 스키마를 짤 수 있는지가 전부 여기 정의되어 있는데요. 이번 편에서는 그 타입들을 하나씩 둘러보겠습니다. 참고로 스키마를 글로 적을 때 쓰는 표기법을 스키마 정의 언어(Schema Definition Language, 이하 SDL)라고 부르는데, 아래 예제들이 모두 SDL입니다.
스키마는 타입들의 집합이고, 세 개의 진입점을 가집니다
스키마는 결국 타입들의 모음입니다. 그리고 그 모음 중에서 특별한 세 타입이 클라이언트가 들어오는 진입점, 즉 루트 타입(root type) 역할을 합니다.
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
지난 편에서 본 세 가지 연산 타입을 기억하시나요? 클라이언트가 query를 보내면 그 요청은 Query 타입에서 시작하고, mutation은 Mutation 타입에서, subscription은 Subscription 타입에서 시작합니다. 그래서 우리가 { user(id: "1") }를 보낼 수 있으려면 Query 타입에 user 필드가 정의되어 있어야 하는 거죠. 대부분의 서버는 관례적으로 이 타입들의 이름을 Query, Mutation, Subscription으로 두기 때문에, 위의 schema { ... } 블록 자체는 생략되는 경우가 많습니다.
스칼라는 더 쪼갤 수 없는 잎입니다
타입 트리의 가장 말단, 즉 더는 하위 필드를 가지지 않는 잎(leaf)에 해당하는 것이 스칼라(Scalar) 입니다. 명세는 다섯 개의 기본 스칼라를 정의하는데요.
- Int — 부호 있는 32비트 정수입니다.
- Float — 부호 있는 배정밀도 부동소수점 수입니다.
- String — UTF-8 문자열입니다.
- Boolean —
true또는false입니다. - ID — 고유 식별자로, 직렬화될 때는 문자열로 다뤄집니다.
ID가 흥미로운데요. 값 자체는 문자열처럼 생겼지만 “사람이 읽으라고 있는 게 아니라 고유 식별용”이라는 의도를 타입으로 드러냅니다. 그래서 String과 똑같이 동작하더라도 ID로 선언하면 읽는 사람에게 “이건 식별자다”라는 신호를 줄 수 있습니다.
기본 스칼라로 부족하면 커스텀 스칼라(custom scalar) 를 직접 정의할 수 있습니다. 날짜나 이메일처럼 형식이 정해진 값이 대표적인데요.
scalar DateTime @specifiedBy(url: "https://scalars.graphql.org/datetime")
여기 붙은 @specifiedBy 디렉티브는 이 스칼라가 어떤 형식을 따르는지 규격 문서의 URL로 가리킵니다. 커스텀 스칼라는 명세가 직렬화 방식까지 정해주지 않기 때문에, 이렇게 외부 규격을 명시해두면 클라이언트와 서버가 같은 형식을 약속할 수 있습니다.
객체 타입은 필드의 묶음입니다
우리가 가장 자주 다루는 타입은 객체 타입(Object) 입니다. 객체 타입은 이름과 필드들의 묶음으로 정의되는데요.
type User {
id: ID!
name: String!
email: String
posts: [Post!]!
}
각 필드에는 이름과 반환 타입이 있고, 필드도 인자를 가질 수 있습니다. posts: [Post!]!처럼 필드가 다른 객체 타입을 반환하면, 클라이언트는 그 아래로 계속 파고들며 그래프를 탐색할 수 있습니다. 바로 이 점이 GraphQL을 “그래프”로 만들어주는 핵심인데요. 타입과 타입이 필드로 연결되면서 거대한 그래프를 이루고, 쿼리는 그 그래프를 따라 원하는 만큼만 길어 올리는 셈입니다.
필드를 더 이상 쓰지 않게 됐을 때는 바로 지우는 대신 @deprecated 디렉티브로 표시할 수 있습니다.
type User {
name: String!
fullName: String! @deprecated(reason: "name 필드를 사용하세요")
}
이렇게 해두면 기존 클라이언트는 계속 동작하면서도, 도구가 이 필드에 “더는 쓰지 말라”는 경고를 띄워줍니다. September 2025 에디션에서는 이 @deprecated를 인자나 입력 필드에도 붙일 수 있도록 규칙이 더 명확해졌습니다.
인터페이스와 유니온으로 다형성을 표현합니다
여러 타입이 공통점을 갖거나, 한 자리에 여러 타입이 번갈아 나올 수 있을 때를 위해 명세는 두 가지 추상 타입을 제공합니다.
인터페이스(Interface) 는 여러 객체 타입이 공통으로 가져야 할 필드를 정의합니다.
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Post implements Node {
id: ID!
title: String!
}
User와 Post가 모두 Node를 구현하므로, “어떤 Node든 id는 반드시 있다”가 보장됩니다. 그래서 node(id: ...)처럼 인터페이스를 반환하는 필드를 만들어두면, 타입이 무엇이든 공통 필드를 안전하게 요청할 수 있습니다.
유니온(Union) 은 공통 필드 없이 그냥 “이 중 하나”를 표현합니다.
union SearchResult = User | Post | Comment
검색 결과처럼 서로 전혀 다른 타입이 한데 섞여 나올 때 어울리는데요. 유니온은 공통 필드가 없기 때문에, 클라이언트는 지난 편에서 본 인라인 프래그먼트 ... on User { ... }로 타입을 좁혀가며 필드를 요청해야 합니다.
열거형과 입력 객체로 입력을 다듬습니다
열거형(Enum) 은 미리 정해진 값들 중 하나만 허용하는 타입입니다.
enum Episode {
NEWHOPE
EMPIRE
JEDI
}
문자열로 아무 값이나 받는 대신 열거형을 쓰면, 허용된 값 외에는 검증 단계에서 막히기 때문에 입력 실수를 줄일 수 있습니다.
그런데 지금까지 본 객체 타입은 “출력”에 쓰는 타입입니다. 인자나 변수로 복잡한 값을 “입력”받을 때는 별도의 입력 객체(Input Object) 를 써야 하는데요.
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
type Mutation {
createPost(input: CreatePostInput!): Post
}
입력 객체와 출력 객체를 굳이 나누는 이유는, 출력에만 의미 있는 요소(인자를 받는 필드, 인터페이스 구현 등)가 입력에는 들어가면 안 되기 때문입니다. 그래서 명세는 둘을 다른 종류의 타입으로 엄격히 구분합니다.
여기에 September 2025 에디션이 정식으로 추가한 것이 @oneOf 입력 객체입니다. 입력 객체에 @oneOf를 붙이면 “이 안의 필드 중 정확히 하나만 채워라”라는 제약이 생깁니다.
input UserBy @oneOf {
id: ID
email: String
username: String
}
“id로 찾거나, email로 찾거나, username으로 찾되 셋 중 하나만”이라는 의미를 타입 한 줄로 표현한 건데요. 예전에는 이런 “여럿 중 하나” 제약을 리졸버 안에서 일일이 검사해야 했지만, 이제는 스키마 차원에서 보장됩니다. 자세한 배경은 시리즈 마지막 편에서 다시 다루겠습니다.
List와 Non-Null은 타입을 감싸는 래퍼입니다
마지막으로 지금까지 예제에 계속 등장했던 대괄호 []와 느낌표 !의 정체를 짚고 넘어가겠습니다. 이 둘은 그 자체로 타입이 아니라, 다른 타입을 감싸는 래핑 타입(wrapping type) 입니다.
[Post]처럼 대괄호로 감싸면 List, 즉 그 타입의 목록이 됩니다. 그리고 타입 뒤에 !를 붙이면 Non-Null, 즉 절대 null이 될 수 없는 값이라는 뜻인데요. 이 둘은 겹쳐 쓸 수 있어서, 앞서 본 [Post!]!는 다음과 같이 읽힙니다.
posts: [Post!]!
안쪽 Post!는 “리스트의 각 원소는 null이 아니다”, 바깥쪽 [...]!는 “리스트 자체도 null이 아니다”라는 뜻입니다. 그래서 빈 배열 []은 허용되지만 null이나 [null]은 허용되지 않습니다. 이렇게 래퍼를 어디에 붙이느냐에 따라 의미가 미묘하게 달라지는데, 이 Non-Null이 실제 실행 중에 어떤 흥미로운 결과를 일으키는지는 실행(Execution) 편에서 자세히 다루겠습니다.
마치며
이번 편에서는 GraphQL 명세 제3장 Type System을 따라, 스키마를 이루는 타입들을 둘러봤습니다. 스칼라라는 잎에서 시작해 객체 타입으로 그래프를 엮고, 인터페이스와 유니온으로 다형성을 표현하며, 열거형과 입력 객체로 입력을 다듬고, List와 Non-Null 래퍼로 타입의 모양을 정교하게 가다듬는 흐름을 살펴봤는데요. September 2025에서 들어온 @oneOf도 맛봤습니다.
여기까지 오면 자연스럽게 한 가지 궁금증이 생깁니다. GraphiQL은 어떻게 우리가 입력하기도 전에 스키마의 모든 타입과 필드를 알고 자동완성을 띄워주는 걸까요? 그 비밀이 바로 다음 편 인트로스펙션(Introspection)에 있습니다. 스키마가 자기 자신을 설명하는 놀라운 방법을 함께 살펴보겠습니다. 타입을 코드로 직접 정의해보고 싶으시다면 Apollo Server로 GraphQL 서버 만들기를 함께 읽어보세요.
타입 시스템의 모든 규칙은 GraphQL September 2025 명세의 Type System 장에 정리되어 있습니다.
This work is licensed under
CC BY 4.0