01 · SDL 문법

이 문서가 답하는 질문: Schema Definition Language의 문법은 무엇이고, ![T]의 조합이 만드는 4가지 nullability는 어떻게 다른가? 한 줄 답 (Pyramid Top): “SDL은 type + field + ! + [T] 4요소만으로 모든 GraphQL 계약을 표현하고, 그중 !의 위치가 의미의 90%를 결정한다.”


Why — 왜 SDL이 존재하는가

GraphQL 첫 버전(2015)은 스키마를 JavaScript 코드로만 정의했다. new GraphQLObjectType({ name: 'User', fields: { ... } }) 같은 형태. 문제는 두 가지였다:

  1. 언어 종속: Python·Go·Rust 서버가 같은 스키마를 공유할 방법이 없다.
  2. 사람이 읽기 어려움: 200줄짜리 스키마가 600줄 코드가 된다.

SDL(Schema Definition Language)은 언어 무관한 텍스트 표현을 위해 2016년 RFC로 추가되었다. 지금은 GraphQL Spec October 2021 §3에 정식 포함되어 있다.

핵심 통찰: SDL은 데이터 정의가 아니라 계약 정의다. 어떤 모양의 데이터가 어떤 이름으로 요청·반환 가능한지를 선언한다.


How — 4개의 빌딩 블록

1) type — object type 정의

type User {
  id: ID!
  name: String!
  email: String
}

User라는 object type을 선언했다. 세 필드를 가진다.

2) Root types — Query, Mutation, Subscription

GraphQL 스키마는 *진입점(entry point)*을 3개의 root type으로 지정한다.

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}
 
type Query {
  user(id: ID!): User
  users: [User!]!
}
 
type Mutation {
  createUser(input: CreateUserInput!): User!
}
 
type Subscription {
  userCreated: User!
}
  • Query: 읽기 진입점 — 부작용 없어야 한다는 관례가 있다(스펙 강제 아님).
  • Mutation: 쓰기 진입점 — 직렬(serial) 실행.
  • Subscription: 스트림 진입점 — WebSocket·SSE로 push.

schema { ... } 블록은 root type 이름이 Query/Mutation/Subscription이면 생략 가능. 대부분의 서버가 이 관례를 따른다.

3) ! — non-null

GraphQL의 가장 자주 틀리는 부분이다. 기본은 nullable이고, !가 있어야 non-null이 된다.

type User {
  id: ID!          # non-null — null 절대 안 옴
  name: String     # nullable — null 올 수 있음
}

TypeScript와 반대 방향이다. TS는 string = non-null, string | null = nullable. GraphQL은 String = nullable, String! = non-null.

4) [T] — list

type Query {
  users: [User]      # nullable list of nullable User
  tags: [String!]    # nullable list of non-null String
  posts: [Post]!     # non-null list of nullable Post
  ids: [ID!]!        # non-null list of non-null ID
}

[T]!의 조합은 4가지 의미를 만든다.

표기리스트 자체원소의미
[T]nullablenullable가장 약함. 리스트도 null, 원소도 null 가능
[T]!non-nullnullable리스트는 보장, 원소는 null 가능 (예: [Post]! = 빈 배열일 수는 있지만 null 아님)
[T!]nullablenon-null리스트가 null이면 필드 자체가 null
[T!]!non-nullnon-null가장 강함. 대부분의 list 응답은 이걸 써야 한다

[User!]!는 “User 리스트는 반드시 있고(빈 배열 가능), 그 안에 null은 없다”는 뜻.


What — 구체 사양

SDL의 모든 키워드 (October 2021 spec §3)

schema      type        interface   union       enum
input       scalar      directive   extend
query       mutation    subscription
implements  on          repeatable
true        false       null

주석

"한 줄 설명 (description, introspection에 노출)"
type User {
  "사용자의 고유 ID"
  id: ID!
 
  """
  여러 줄
  description
  """
  bio: String
}
 
# 이건 그냥 주석 (introspection에 노출 안 됨)

"...""""..."""는 description이고 introspection으로 클라이언트에 보인다. #는 진짜 주석이다.

Field argument

type Query {
  user(id: ID!): User
  users(
    limit: Int = 20      # default value
    offset: Int = 0
    role: Role
  ): [User!]!
}

인자에 default value를 주면 클라이언트가 보내지 않아도 그 값이 들어온다.

extend — 스키마 분할

# user.graphql
type Query {
  user(id: ID!): User
}
 
# post.graphql
extend type Query {
  post(id: ID!): Post
}

extend같은 type을 여러 파일에서 나눠 정의할 때 쓴다. Federation에서 핵심.

전체 예제 — 미니 블로그 스키마

"""사용자 — 시스템의 인증 주체"""
type User {
  id: ID!
  name: String!
  email: String!
  role: Role!
  posts: [Post!]!
}
 
enum Role {
  ADMIN
  EDITOR
  READER
}
 
type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  tags: [String!]!
  publishedAt: String
}
 
type Query {
  me: User
  user(id: ID!): User
  posts(limit: Int = 20, offset: Int = 0): [Post!]!
}
 
input CreatePostInput {
  title: String!
  body: String!
  tags: [String!] = []
}
 
type Mutation {
  createPost(input: CreatePostInput!): Post!
}

이 33줄이 완결된 계약이다 — 클라이언트가 어떤 쿼리를 보낼 수 있고 어떤 응답을 받는지 전부 적혀 있다.


What-if — 자주 틀리는 패턴

함정 1) !를 빼먹어서 모든 게 nullable

type User {
  id: ID
  name: String
  email: String
}

이러면 어떤 필드도 null 아님이 보장 안 된다. 클라이언트가:

user?.id?.toLowerCase()  // 매번 옵셔널 체이닝

원칙: 응답 필드는 기본 non-null로 적고, 진짜로 null이 가능한 것만 ! 빼라.

함정 2) [Post][Post!]!로 안 쓰기

type Query {
  posts: [Post]    # ⚠️ 리스트도 null, 원소도 null
}

응답 처리할 때:

const data = res.posts ?? []     // 리스트 null 처리
for (const p of data) {
  if (!p) continue                // 원소 null 처리
  console.log(p.title)
}

[Post!]!로 적으면 두 줄 다 사라진다.

함정 3) Query/Mutation을 안 적기

type User { id: ID! }

이건 진입점 없는 스키마다. 서버 구동 자체가 실패한다. 최소 하나의 Query field가 있어야 스펙상 valid.

함정 4) field name으로 __로 시작

type User {
  __secret: String   # ⚠️ 스펙 위반
}

__로 시작하는 이름은 introspection 시스템 예약이다(__schema, __type, __typename). 사용자 필드에 쓰면 등록 실패.

함정 5) circular type을 lazy로 안 푸는 code-first

schema-first(SDL)는 자연스럽게 순환 참조를 푼다 — 단순히 텍스트라서:

type User { posts: [Post!]! }
type Post { author: User! }

code-first(JS)는:

// ❌ ReferenceError — Post가 아직 정의 안 됨
const User = new GraphQLObjectType({
  fields: { posts: { type: new GraphQLList(Post) } }
})
 
// ✅ fields를 함수로 감싸 lazy 평가
const User = new GraphQLObjectType({
  fields: () => ({ posts: { type: new GraphQLList(Post) } })
})

Insight — 흥미로운 이야기

“non-null이 default였다면 GraphQL은 다른 언어가 되었을 것”

2015년 spec 초안에서 non-null이 default이고 ?가 nullable 안이 논의됐다. 거부된 이유: 부분 응답(partial response) 때문이다. GraphQL은 필드 하나가 실패해도 나머지를 응답으로 돌려준다. 실패한 필드는 null이 된다. 만약 default가 non-null이었다면, 한 필드의 실패가 부모 객체 전체를 null로 만들고, 또 그 부모가 non-null이면 그 위까지 null이 전파 — 결국 응답 전체가 무너진다. 그래서 기본 nullable부분 실패에 대한 회복력의 다른 이름이다.

[T!]!는 약 84%의 list field에서 옳다”

GitHub 공개 스키마(2024년 기준 1900+ 타입)를 분석하면 list field의 약 84%가 [T!]!다. 빈 배열은 가능하지만 null이거나 null 원소가 의미 있는 경우는 드물다. 새 필드를 적을 때 기본을 [T!]!로 두고, null이 필요한 이유가 있을 때만 약하게 적는 습관이 안전하다.


요약 + Mermaid

요약: SDL은 type·field·!·[T] 4 요소다. Query/Mutation/Subscription이 진입점이고, !non-null, [T]list다. [T!]!가 list field의 안전한 기본값. 기본이 nullable인 이유는 부분 실패에 대한 회복력이다. 다음 문서(02-types-scalars-objects)는 이 빌딩블록 위에 어떤 타입이 들어가는지를 다룬다.