Showing posts with label graphQL. Show all posts
Showing posts with label graphQL. Show all posts

Friday, June 20, 2025

견고한 GraphQL 스키마 설계를 위한 핵심 원칙

GraphQL은 현대적인 API 개발의 패러다임을 바꾸고 있습니다. 클라이언트가 필요한 데이터만 정확하게 요청할 수 있게 함으로써, 오버페칭(over-fetching)과 언더페칭(under-fetching) 문제를 해결하고 프론트엔드와 백엔드 개발자 간의 협업을 극적으로 개선합니다. 하지만 GraphQL의 모든 잠재력을 끌어내기 위해서는 가장 중요한 첫 단추, 바로 '스키마(Schema) 설계'를 잘 꿰어야 합니다.

GraphQL 스키마는 API가 제공할 수 있는 모든 데이터와 기능에 대한 강력한 '계약서'입니다. 이 계약서가 명확하고, 유연하며, 확장 가능하게 작성되지 않는다면, 프로젝트는 머지않아 유지보수의 늪에 빠지거나 성능 문제에 직면하게 될 것입니다. 이 글에서는 수많은 프로젝트를 통해 검증된, 시간이 지나도 변치 않는 견고한 GraphQL 스키마 설계의 핵심 원칙들을 깊이 있게 다루어 보겠습니다.

1. 데이터베이스가 아닌, 클라이언트 중심으로 생각하기

가장 흔한 실수 중 하나는 데이터베이스의 구조를 그대로 GraphQL 스키마에 반영하는 것입니다. GraphQL 스키마는 백엔드의 데이터 모델이 아니라, 클라이언트(프론트엔드)가 데이터를 어떻게 소비하는가에 초점을 맞춰야 합니다.

예를 들어, 사용자 프로필 페이지를 만든다고 가정해 봅시다. 이 페이지에는 사용자의 이름, 프로필 사진, 그리고 최근 작성한 게시물 5개가 필요합니다. 데이터베이스에서는 users 테이블, user_profiles 테이블, posts 테이블에 데이터가 나뉘어 저장되어 있을 수 있습니다.

나쁜 설계는 이를 그대로 노출하는 것입니다:

type Query {
  getUserById(id: ID!): User
  getUserProfileByUserId(userId: ID!): UserProfile
  getPostsByUserId(userId: ID!, limit: Int): [Post]
}

이런 방식은 클라이언트가 사용자 프로필을 가져오기 위해 세 번의 API 호출을 해야 하는 '언더페칭' 문제를 야기합니다. REST API에서 겪었던 문제를 그대로 반복하는 셈입니다.

좋은 설계는 클라이언트의 요구사항을 하나의 그래프로 묶어주는 것입니다.

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

type User {
  id: ID!
  name: String!
  email: String!
  avatarUrl: String
  recentPosts(limit: Int = 5): [Post!]!
}

type Post {
  id: ID!
  title: String!
  createdAt: DateTime!
}

이제 클라이언트는 단 한 번의 쿼리로 필요한 모든 정보를 얻을 수 있습니다. 백엔드에서는 user 리졸버와 recentPosts 리졸버가 각각 다른 데이터 소스(데이터베이스, 다른 마이크로서비스 등)에서 데이터를 가져오도록 구현하면 됩니다. 이처럼 스키마는 클라이언트의 '뷰(View)'를 중심으로 설계되어야 합니다.

2. 명확하고 예측 가능한 네이밍 컨벤션

잘 지은 이름은 코드의 가독성을 높이고 API의 사용성을 크게 향상시킵니다. 스키마의 모든 요소(타입, 필드, 인자, Enum 등)는 일관되고 예측 가능한 네이밍 컨벤션을 따라야 합니다.

  • 타입(Types): PascalCase를 사용합니다. (예: User, BlogPost, ProductReview)
  • 필드(Fields) 및 인자(Arguments): camelCase를 사용합니다. (예: firstName, totalCount, orderBy)
  • Enum 타입: PascalCase를 사용합니다. (예: SortDirection)
  • Enum 값: ALL_CAPS 또는 SCREAMING_SNAKE_CASE를 사용합니다. (예: ASC, DESC, PUBLISHED)

뮤테이션(Mutation) 네이밍

데이터를 변경하는 뮤테이션은 특히 더 명확한 네이밍이 중요합니다. 예측 가능한 패턴을 사용하면 클라이언트 개발자가 뮤테이션의 역할을 쉽게 유추할 수 있습니다.

[동사] + [명사] 형태를 권장합니다.

  • 생성: createPost, addUserToTeam
  • 수정: updateUserSettings, editComment
  • 삭제: deletePost, removeUserFromTeam

이러한 일관성은 자동완성 기능을 제공하는 개발 도구(예: GraphiQL)와 함께 사용할 때 엄청난 시너지를 발휘합니다.

3. 미래를 대비하는 확장성 있는 설계

API는 살아있는 유기체와 같아서 계속해서 변화하고 성장합니다. 처음부터 확장성을 염두에 두지 않으면, 작은 기능 추가가 스키마 전체를 흔드는 '파괴적인 변경(Breaking Change)'으로 이어질 수 있습니다.

절대 필드를 삭제하지 말고, `@deprecated`를 사용하세요

더 이상 사용되지 않는 필드가 생겼다고 해서 바로 스키마에서 삭제하면 안 됩니다. 해당 필드를 사용하고 있는 구버전 클라이언트 앱들이 즉시 오류를 일으킬 것입니다. 대신, @deprecated 지시어를 사용하여 필드가 곧 지원 중단될 것임을 알리세요.

type User {
  id: ID!
  name: String!
  # 'fullName' 필드로 대체되었습니다.
  oldName: String @deprecated(reason: "Use 'name' field instead.")
}

이렇게 하면 개발 도구에서 해당 필드에 취소선이 표시되고, 개발자들은 자연스럽게 새로운 필드를 사용하게 됩니다. 충분한 시간이 지난 후, 사용 현황을 모니터링하고 안전하다고 판단될 때 필드를 제거할 수 있습니다.

고정된 값의 집합에는 Enum을 사용하세요

게시물의 상태(예: 'DRAFT', 'PUBLISHED', 'ARCHIVED')와 같이 미리 정해진 값들만 허용해야 하는 필드가 있다면, 문자열(String) 타입 대신 Enum을 사용하세요.

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type Post {
  id: ID!
  title: String!
  status: PostStatus!
}

Enum을 사용하면 다음과 같은 장점이 있습니다.

  • 타입 안정성: 오타(예: 'PUBLISHD')를 컴파일 타임에 방지할 수 있습니다.
  • 자기 서술적: 스키마만 봐도 어떤 값들이 가능한지 명확히 알 수 있습니다.
  • 서버 유효성 검사: 서버는 Enum에 정의되지 않은 값이 들어오면 자동으로 요청을 거부합니다.

다형성을 위해 인터페이스(Interface)와 유니온(Union)을 활용하세요

검색 결과처럼 여러 다른 타입의 객체들을 반환해야 할 때가 있습니다. 이럴 때 interfaceunion이 매우 유용합니다.

  • 인터페이스(Interface): 여러 타입이 공통된 필드를 가지고 있을 때 사용합니다. 예를 들어, BookMovie는 모두 idtitle을 가질 수 있습니다.
interface Searchable {
  id: ID!
  title: String!
}

type Book implements Searchable {
  id: ID!
  title: String!
  author: String!
}

type Movie implements Searchable {
  id: ID!
  title: String!
  director: String!
}

type Query {
  search(query: String!): [Searchable!]!
}
  • 유니온(Union): 공통 필드가 없는 서로 다른 타입들을 묶을 때 사용합니다.
union SearchResult = User | Post | Comment

type Query {
  globalSearch(query: String!): [SearchResult!]!
}

클라이언트는 ... on TypeName 구문을 사용하여 각 타입에 맞는 필드를 요청할 수 있어 매우 유연한 쿼리가 가능해집니다.

4. 강력한 타입 시스템을 최대한 활용하기: Nullability

GraphQL의 타입 시스템은 Nullable과 Non-Nullable(!)을 명확하게 구분합니다. 이를 적극적으로 활용하면 API의 안정성을 크게 높일 수 있습니다.

기본 원칙: 모든 필드는 기본적으로 Non-Nullable(!)로 만드세요. 그리고 해당 필드가 정말로 비어 있을 수 있는 경우에만 Nullable로 변경하세요. 예를 들어, 사용자의 idemail은 항상 존재해야 하므로 ID!, String!으로 선언하는 것이 좋습니다. 반면, profileImageUrl은 프로필 사진을 등록하지 않은 사용자가 있을 수 있으므로 String(Nullable)으로 선언할 수 있습니다.

리스트(List)의 경우, Nullability는 네 가지 조합이 가능하며 각각 의미가 다릅니다.

  • [String]: 리스트 자체가 null일 수 있고, 리스트 안의 아이템도 null일 수 있습니다. (예: null, [], ['a', null, 'b'])
  • [String!]: 리스트 자체는 null일 수 있지만, 리스트가 존재한다면 그 안의 아이템은 null일 수 없습니다. (예: null, [], ['a', 'b'])
  • [String]!: 리스트 자체는 null일 수 없지만(항상 배열), 그 안의 아이템은 null일 수 있습니다. (예: [], ['a', null, 'b'])
  • [String!]!: 리스트와 그 안의 아이템 모두 null일 수 없습니다. 가장 일반적으로 사용되는 형태입니다. (예: [], ['a', 'b'])

[Post!]!와 같이 명확하게 Nullability를 정의하면, 클라이언트는 불필요한 null 체크 코드를 줄일 수 있고, API는 더욱 예측 가능하고 안정적으로 동작합니다.

5. 대용량 데이터를 위한 페이징(Pagination) 설계

posts: [Post!]!와 같이 제한 없는 리스트를 반환하는 것은 매우 위험합니다. 수백만 개의 게시물이 있다면 서버는 즉시 다운될 것입니다. 모든 리스트 형태의 필드는 반드시 페이징을 구현해야 합니다.

GraphQL 페이징에는 크게 두 가지 방식이 있습니다.

  1. 오프셋 기반 페이징 (Offset-based): limitoffset(또는 page)을 사용하는 전통적인 방식입니다. 구현이 간단하지만, 실시간으로 데이터가 추가/삭제되는 환경에서는 페이지 중복이나 누락이 발생할 수 있습니다.
  2. 커서 기반 페이징 (Cursor-based): 각 아이템의 고유한 위치를 가리키는 '커서(cursor)'를 사용하는 방식입니다. 상태를 저장하지 않아(stateless) 실시간 데이터 환경에서도 안정적이며, Relay와 같은 GraphQL 클라이언트 라이브러리에서 표준으로 채택하고 있습니다.

강력히 권장하는 방식은 커서 기반 페이징입니다. Relay 스펙을 따르는 것이 일반적이며, 그 구조는 다음과 같습니다.

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

# 연결(Connection) 모델
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

# 엣지(Edge)는 노드(실제 데이터)와 커서를 포함
type PostEdge {
  cursor: String!
  node: Post!
}

# 페이지 정보
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

이 구조는 처음에는 복잡해 보일 수 있지만, 무한 스크롤과 같은 현대적인 UI를 매우 안정적으로 구현할 수 있게 해주는 표준적인 패턴입니다.

6. 예측 가능한 뮤테이션을 위한 패턴

좋은 뮤테이션은 단순히 데이터를 변경하는 것뿐만 아니라, 그 결과를 예측 가능하고 유용한 방식으로 클라이언트에게 돌려주어야 합니다. 이를 위해 두 가지 핵심 패턴을 적용하는 것이 좋습니다.

1. 단일 인자 원칙 (Single Input Object)

뮤테이션에 여러 개의 인자를 직접 전달하는 대신, 모든 인자를 포함하는 고유한 input 타입을 하나 만들어 전달하세요.

나쁜 예:

type Mutation {
  createPost(title: String!, content: String, authorId: ID!): Post
}

여기에 새로운 인자(예: tags: [String!])를 추가하면, 기존 클라이언트와의 호환성이 깨질 수 있습니다.

좋은 예:

input CreatePostInput {
  title: String!
  content: String
  authorId: ID!
  clientMutationId: String # 클라이언트에서 요청을 식별하기 위한 ID (선택사항)
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

이제 CreatePostInput에 새로운 필드를 추가해도 기존 클라이언트는 영향을 받지 않습니다. 이는 파괴적이지 않은 변경(Non-breaking change)을 가능하게 합니다.

2. 페이로드 타입 원칙 (Payload Type)

뮤테이션이 단순히 생성/수정된 객체만 반환하는 대신, 뮤테이션의 결과를 담는 고유한 payload 타입을 반환하게 하세요.

좋은 예 (계속):

type CreatePostPayload {
  post: Post
  errors: [UserError!]!
  clientMutationId: String
}

type UserError {
  message: String!
  field: [String!] # 오류가 발생한 입력 필드
}

이 페이로드 구조는 다음과 같은 정보를 담을 수 있어 매우 유연합니다.

  • 변경된 데이터 (post): 클라이언트가 변경된 데이터를 다시 요청할 필요 없이 즉시 UI를 업데이트할 수 있습니다.
  • 사용자 수준의 오류 (errors): "제목은 5자 이상이어야 합니다."와 같은 유효성 검사 오류를 구조화된 형태로 전달할 수 있습니다.
  • 클라이언트 ID (clientMutationId): 클라이언트가 보낸 ID를 그대로 돌려주어, 비동기 환경에서 어떤 요청에 대한 응답인지 쉽게 매칭할 수 있습니다.

결론: 좋은 스키마는 최고의 투자

GraphQL 스키마 설계는 단순히 API의 엔드포인트를 정의하는 행위를 넘어, 애플리케이션의 데이터 흐름과 개발자 경험 전체를 설계하는 과정입니다. 클라이언트 중심적 사고, 명확한 네이밍, 확장성, 강력한 타입 시스템 활용, 표준화된 페이징 및 뮤테이션 패턴은 견고하고 유지보수하기 쉬운 API를 만드는 데 필수적인 초석입니다.

초기에 스키마 설계에 더 많은 시간을 투자하는 것은 장기적으로 개발 속도를 높이고, 버그를 줄이며, 프론트엔드와 백엔드 간의 원활한 협업을 가능하게 하는 최고의 투자가 될 것입니다. 이 원칙들을 바탕으로 여러분의 다음 GraphQL 프로젝트가 성공적으로 구축되기를 바랍니다.

Core Principles for a Robust GraphQL Schema Design

GraphQL is revolutionizing the way modern APIs are built. By allowing clients to request exactly the data they need, it solves the chronic problems of over-fetching and under-fetching, dramatically improving collaboration between frontend and backend developers. However, to unlock the full potential of GraphQL, you must get the most critical first step right: designing the schema.

A GraphQL schema is a powerful "contract" for all the data and capabilities an API can offer. If this contract isn't clear, flexible, and scalable, a project will soon find itself mired in maintenance hell or facing severe performance issues. In this article, we will delve deep into the time-tested, core principles of robust GraphQL schema design that have been validated across countless projects.

1. Think from the Client's Perspective, Not the Database's

One of the most common mistakes is to directly mirror the database structure in the GraphQL schema. A GraphQL schema should not be a reflection of your backend's data model, but rather should be tailored to how clients (the frontend) consume the data.

For instance, let's say you're building a user profile page. This page needs the user's name, their profile picture, and their 5 most recent posts. In the database, this data might be stored across a users table, a user_profiles table, and a posts table.

A poor design would expose this structure directly:

type Query {
  getUserById(id: ID!): User
  getUserProfileByUserId(userId: ID!): UserProfile
  getPostsByUserId(userId: ID!, limit: Int): [Post]
}

This approach forces the client to make three separate API calls to fetch a single user profile, creating the "under-fetching" problem. It's essentially repeating the same issues we faced with REST APIs.

A good design aggregates the client's requirements into a single, cohesive graph.

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

type User {
  id: ID!
  name: String!
  email: String!
  avatarUrl: String
  recentPosts(limit: Int = 5): [Post!]!
}

type Post {
  id: ID!
  title: String!
  createdAt: DateTime!
}

Now, the client can get all the information it needs with a single query. On the backend, the user resolver and the recentPosts resolver can be implemented to fetch data from different sources (the database, another microservice, etc.). The schema should be designed around the client's "view" of the data.

2. Clear and Predictable Naming Conventions

A well-chosen name enhances code readability and significantly improves the usability of an API. Every element in your schema (types, fields, arguments, enums) should follow a consistent and predictable naming convention.

  • Types: Use PascalCase. (e.g., User, BlogPost, ProductReview)
  • Fields & Arguments: Use camelCase. (e.g., firstName, totalCount, orderBy)
  • Enum Types: Use PascalCase. (e.g., SortDirection)
  • Enum Values: Use ALL_CAPS or SCREAMING_SNAKE_CASE. (e.g., ASC, DESC, PUBLISHED)

Mutation Naming

For mutations, which alter data, clear naming is especially critical. Using a predictable pattern helps client developers easily infer a mutation's purpose.

The recommended format is [Verb] + [Noun].

  • Create: createPost, addUserToTeam
  • Update: updateUserSettings, editComment
  • Delete: deletePost, removeUserFromTeam

This consistency creates powerful synergy when used with development tools that offer autocompletion, like GraphiQL.

3. Design for Extensibility to Future-Proof Your Schema

APIs are like living organisms; they constantly evolve and grow. If you don't design with extensibility in mind from the start, a small feature addition can lead to a "breaking change" that ripples through your entire schema.

Never Delete Fields; Use `@deprecated` Instead

If a field is no longer in use, don't just delete it from the schema. Older client apps that still use that field will immediately break. Instead, use the @deprecated directive to signal that the field will be discontinued soon.

type User {
  id: ID!
  name: String!
  # This field is replaced by 'name'.
  oldName: String @deprecated(reason: "Use 'name' field instead.")
}

This will cause development tools to display the field with a strikethrough, naturally guiding developers to use the new field. After a sufficient amount of time, you can monitor its usage and safely remove the field when it's no longer being accessed.

Use Enums for Fixed Sets of Values

For fields that should only accept a predefined set of values, like a post's status ('DRAFT', 'PUBLISHED', 'ARCHIVED'), use an Enum instead of a String type.

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type Post {
  id: ID!
  title: String!
  status: PostStatus!
}

Using enums provides several benefits:

  • Type Safety: It prevents typos (e.g., 'PUBLISHD') at compile time.
  • Self-Documenting: The schema clearly communicates the possible values.
  • Server-Side Validation: The server will automatically reject requests with values not defined in the enum.

Leverage Interfaces and Unions for Polymorphism

Sometimes you need to return a list of objects of different types, such as in a search result. This is where interface and union types are incredibly useful.

  • Interfaces: Use when multiple types share a common set of fields. For example, both Book and Movie might have an id and a title.
interface Searchable {
  id: ID!
  title: String!
}

type Book implements Searchable {
  id: ID!
  title: String!
  author: String!
}

type Movie implements Searchable {
  id: ID!
  title: String!
  director: String!
}

type Query {
  search(query: String!): [Searchable!]!
}
  • Unions: Use when you need to group different types that do not share common fields.
union SearchResult = User | Post | Comment

type Query {
  globalSearch(query: String!): [SearchResult!]!
}

Clients can use the ... on TypeName syntax to request fields specific to each type, enabling highly flexible queries.

4. Maximize the Power of the Type System: Nullability

GraphQL's type system clearly distinguishes between Nullable and Non-Nullable (!). Actively using this feature can significantly increase the stability of your API.

The guiding principle: make all fields Non-Nullable (!) by default. Only change a field to be Nullable if there is a legitimate reason for it to be empty. For example, a user's id or email should always exist, so it's best to declare them as ID! and String!. On the other hand, a profileImageUrl could be String (Nullable) since a user might not have uploaded a profile picture.

For lists, there are four possible combinations of nullability, each with a different meaning:

  • [String]: The list itself can be null, and the items within it can also be null. (e.g., null, [], ['a', null, 'b'])
  • [String!]: The list itself can be null, but if it exists, its items cannot be null. (e.g., null, [], ['a', 'b'])
  • [String]!: The list itself cannot be null (it's always an array), but its items can be null. (e.g., [], ['a', null, 'b'])
  • [String!]!: Neither the list nor its items can be null. This is the most commonly used form. (e.g., [], ['a', 'b'])

By clearly defining nullability, such as with [Post!]!, you reduce the need for unnecessary null-checking code on the client side, making the API more predictable and robust.

5. Designing Pagination for Large Datasets

Returning an unbounded list like posts: [Post!]! is extremely dangerous. If you have millions of posts, the server could crash instantly. Every list-like field must implement pagination.

There are two main approaches to GraphQL pagination:

  1. Offset-based Pagination: The traditional method using limit and offset (or page). It's simple to implement but can lead to duplicate or skipped items in real-time environments where data is frequently added or deleted.
  2. Cursor-based Pagination: This method uses a 'cursor' that points to a unique position of an item. It's stateless and stable even in real-time data environments, and it has been adopted as the standard by GraphQL client libraries like Relay.

Cursor-based pagination is the strongly recommended approach. Following the Relay specification is common practice, and its structure is as follows:

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

# The Connection model
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

# An Edge contains the node (the actual data) and its cursor
type PostEdge {
  cursor: String!
  node: Post!
}

# Page information
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

This structure may seem complex at first, but it's a standard pattern that allows for the highly stable implementation of modern UIs like infinite scrolling.

6. Patterns for Predictable Mutations

A good mutation doesn't just change data; it should also return the result to the client in a predictable and useful way. To achieve this, it's best to apply two key patterns.

1. The Single Input Object Principle

Instead of passing multiple arguments directly to a mutation, create a single, unique input type that contains all the arguments.

Bad Example:

type Mutation {
  createPost(title: String!, content: String, authorId: ID!): Post
}

Adding a new argument here (e.g., tags: [String!]) could break compatibility with existing clients.

Good Example:

input CreatePostInput {
  title: String!
  content: String
  authorId: ID!
  clientMutationId: String # Optional ID for the client to identify the request
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

Now, you can add new fields to CreatePostInput without affecting existing clients. This enables non-breaking changes.

2. The Payload Type Principle

Instead of having the mutation return just the created/updated object, have it return a unique payload type that encapsulates the result of the mutation.

Good Example (continued):

type CreatePostPayload {
  post: Post
  errors: [UserError!]!
  clientMutationId: String
}

type UserError {
  message: String!
  field: [String!] # The input field that caused the error
}

This payload structure is highly flexible and can contain:

  • The changed data (post): Allows the client to update the UI immediately without needing to refetch the data.
  • User-level errors (errors): Can deliver structured validation errors like "Title must be at least 5 characters long."
  • Client ID (clientMutationId): Returns the ID sent by the client, making it easy to match responses to requests in an asynchronous environment.

Conclusion: A Good Schema is the Best Investment

GraphQL schema design is more than just defining API endpoints; it's the process of designing the entire data flow and developer experience of an application. Client-centric thinking, clear naming, extensibility, leveraging the strong type system, and standardized pagination and mutation patterns are the essential cornerstones for building a robust and maintainable API.

Investing more time in schema design upfront is the best investment you can make, as it will accelerate development speed, reduce bugs, and enable seamless collaboration between frontend and backend in the long run. We hope these principles will help you successfully build your next GraphQL project.

堅牢なGraphQLスキーマ設計のための核心原則

GraphQLは、現代のAPI開発のパラダイムを大きく変えつつあります。クライアントが必要なデータだけを正確にリクエストできるようにすることで、オーバーフェッチ(over-fetching)とアンダーフェッチ(under-fetching)の問題を解決し、フロントエンドとバックエンド開発者間の協業を劇的に改善します。しかし、GraphQLのポテンシャルを最大限に引き出すためには、最も重要な最初のステップ、すなわち「スキーマ(Schema)設計」を正しく行う必要があります。

GraphQLスキーマは、APIが提供できるすべてのデータと機能に対する強力な「契約書」です。この契約書が明確で、柔軟性があり、拡張可能に作成されていなければ、プロジェクトは遠からずメンテナンスの泥沼にはまるか、パフォーマンス問題に直面することになるでしょう。この記事では、数多くのプロジェクトを通じて検証された、時が経っても色褪せることのない、堅牢なGraphQLスキーマ設計の核心原則を深く掘り下げていきます。

1. データベースではなく、クライアント中心に考える

最もよくある間違いの一つは、データベースの構造をそのままGraphQLスキーマに反映させてしまうことです。GraphQLスキーマはバックエンドのデータモデルではなく、クライアント(フロントエンド)がデータをどのように利用するかに焦点を当てるべきです。

例えば、ユーザープロフィールページを作成すると仮定しましょう。このページには、ユーザーの名前、プロフィール写真、そして最近投稿した記事5件が必要です。データベースでは、データがusersテーブル、user_profilesテーブル、postsテーブルに分かれて保存されているかもしれません。

悪い設計は、これをそのまま公開するものです:

type Query {
  getUserById(id: ID!): User
  getUserProfileByUserId(userId: ID!): UserProfile
  getPostsByUserId(userId: ID!, limit: Int): [Post]
}

この方法では、クライアントがユーザープロフィールを取得するために3回のAPI呼び出しが必要となり、「アンダーフェッチ」問題を引き起こします。これはREST APIで経験した問題をそのまま繰り返しているのと同じです。

良い設計は、クライアントの要求を一つのグラフにまとめることです。

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

type User {
  id: ID!
  name: String!
  email: String!
  avatarUrl: String
  recentPosts(limit: Int = 5): [Post!]!
}

type Post {
  id: ID!
  title: String!
  createdAt: DateTime!
}

これで、クライアントはたった一度のクエリで必要なすべての情報を取得できます。バックエンドでは、userリゾルバとrecentPostsリゾルバがそれぞれ異なるデータソース(データベース、他のマイクロサービスなど)からデータを取得するように実装すればよいのです。このように、スキーマはクライアントの「ビュー(View)」を中心に設計されるべきです。

2. 明確で予測可能な命名規則

優れた名前はコードの可読性を高め、APIの使いやすさを大幅に向上させます。スキーマのすべての要素(型、フィールド、引数、Enumなど)は、一貫性があり予測可能な命名規則に従うべきです。

  • 型(Types): PascalCaseを使用します。(例: User, BlogPost, ProductReview
  • フィールド(Fields)および引数(Arguments): camelCaseを使用します。(例: firstName, totalCount, orderBy
  • Enum型: PascalCaseを使用します。(例: SortDirection
  • Enum値: ALL_CAPSまたはSCREAMING_SNAKE_CASEを使用します。(例: ASC, DESC, PUBLISHED

ミューテーション(Mutation)の命名

データを変更するミューテーションは、特に明確な命名が重要です。予測可能なパターンを使用することで、クライアント開発者はミューテーションの役割を容易に推測できます。

[動詞] + [名詞] の形式を推奨します。

  • 作成: createPost, addUserToTeam
  • 更新: updateUserSettings, editComment
  • 削除: deletePost, removeUserFromTeam

このような一貫性は、自動補完機能を提供する開発ツール(例: GraphiQL)と併用することで、絶大な相乗効果を発揮します。

3. 将来を見据えた拡張性のある設計

APIは生き物のように絶えず変化し、成長します。最初から拡張性を考慮しておかないと、小さな機能追加がスキーマ全体を揺るがす「破壊的変更(Breaking Change)」につながる可能性があります。

フィールドは決して削除せず、`@deprecated`を使用する

使用されなくなったフィールドができたからといって、すぐにスキーマから削除してはいけません。そのフィールドを使用している古いバージョンのクライアントアプリが即座にエラーを起こします。代わりに、@deprecatedディレクティブを使用して、そのフィールドが間もなくサポート終了となることを知らせましょう。

type User {
  id: ID!
  name: String!
  # 'name' フィールドに置き換えられました。
  oldName: String @deprecated(reason: "Use 'name' field instead.")
}

これにより、開発ツールでは該当フィールドに取り消し線が表示され、開発者は自然と新しいフィールドを使用するようになります。十分な時間が経過した後、使用状況を監視し、安全だと判断された時点でフィールドを削除できます。

固定値の集合にはEnumを使用する

投稿のステータス(例: 'DRAFT', 'PUBLISHED', 'ARCHIVED')のように、あらかじめ決められた値のみを許可すべきフィールドには、文字列(String)型ではなくEnumを使用してください。

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type Post {
  id: ID!
  title: String!
  status: PostStatus!
}

Enumを使用すると、次のような利点があります。

  • 型安全性: タイプミス(例: 'PUBLISHD')をコンパイル時に防ぐことができます。
  • 自己文書化: スキーマを見るだけで、どのような値が可能かが明確にわかります。
  • サーバーサイドのバリデーション: サーバーはEnumで定義されていない値が渡されると、自動的にリクエストを拒否します。

多態性のためにインターフェース(Interface)とユニオン(Union)を活用する

検索結果のように、複数の異なる型のオブジェクトを返す必要がある場合があります。このような場合にinterfaceunionが非常に役立ちます。

  • インターフェース(Interface): 複数の型が共通のフィールドを持つ場合に使用します。例えば、BookMovieはどちらもidtitleを持つことができます。
interface Searchable {
  id: ID!
  title: String!
}

type Book implements Searchable {
  id: ID!
  title: String!
  author: String!
}

type Movie implements Searchable {
  id: ID!
  title: String!
  director: String!
}

type Query {
  search(query: String!): [Searchable!]!
}
  • ユニオン(Union): 共通のフィールドを持たない異なる型をグループ化する場合に使用します。
union SearchResult = User | Post | Comment

type Query {
  globalSearch(query: String!): [SearchResult!]!
}

クライアントは... on TypeName構文を使用して、各型に応じたフィールドをリクエストできるため、非常に柔軟なクエリが可能になります。

4. 強力な型システムを最大限に活用する: Nullability

GraphQLの型システムは、NullableとNon-Nullable(!)を明確に区別します。これを積極的に活用することで、APIの安定性を大幅に向上させることができます。

基本原則:すべてのフィールドはデフォルトでNon-Nullable(!)にしましょう。そして、そのフィールドが本当に空である可能性がある場合にのみNullableに変更します。例えば、ユーザーのidemailは常に存在すべきなので、ID!, String!と宣言するのが良いでしょう。一方、profileImageUrlはプロフィール写真を登録していないユーザーもいるため、String(Nullable)と宣言できます。

リスト(List)の場合、Nullabilityには4つの組み合わせがあり、それぞれ意味が異なります。

  • [String]: リスト自体がnullである可能性があり、リスト内のアイテムもnullである可能性があります。(例: null, [], ['a', null, 'b']
  • [String!]: リスト自体はnullである可能性がありますが、リストが存在する場合、その中のアイテムはnullであってはなりません。(例: null, [], ['a', 'b']
  • [String]!: リスト自体はnullであってはなりませんが(常に配列)、その中のアイテムはnullである可能性があります。(例: [], ['a', null, 'b']
  • [String!]!: リストとその中のアイテムの両方ともnullであってはなりません。最も一般的に使用される形式です。(例: [], ['a', 'b']

[Post!]!のように明確にNullabilityを定義することで、クライアントは不要なnullチェックのコードを減らすことができ、APIはより予測可能で安定したものになります。

5. 大量データのためのページネーション(Pagination)設計

posts: [Post!]!のように無制限のリストを返すことは非常に危険です。何百万もの投稿があれば、サーバーは即座にダウンしてしまうでしょう。すべてのリスト形式のフィールドは、必ずページネーションを実装しなければなりません。

GraphQLのページネーションには、大きく分けて2つの方式があります。

  1. オフセットベース・ページネーション(Offset-based): limitoffset(またはpage)を使用する伝統的な方式です。実装は簡単ですが、リアルタイムでデータが追加・削除される環境では、ページの重複や欠落が発生する可能性があります。
  2. カーソルベース・ページネーション(Cursor-based): 各アイテムの一意の位置を指す「カーソル(cursor)」を使用する方式です。ステートレスであるためリアルタイムデータ環境でも安定しており、RelayのようなGraphQLクライアントライブラリで標準として採用されています。

強く推奨されるのは、カーソルベース・ページネーションです。Relayの仕様に従うのが一般的で、その構造は以下のようになります。

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

# コネクション(Connection)モデル
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

# エッジ(Edge)はノード(実際のデータ)とカーソルを含む
type PostEdge {
  cursor: String!
  node: Post!
}

# ページ情報
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

この構造は最初は複雑に見えるかもしれませんが、無限スクロールのような現代的なUIを非常に安定して実装できるようにする標準的なパターンです。

6. 予測可能なミューテーションのためのパターン

良いミューテーションは、単にデータを変更するだけでなく、その結果を予測可能で有用な方法でクライアントに返す必要があります。そのために、2つの重要なパターンを適用することをお勧めします。

1. 単一引数の原則(Single Input Object)

ミューテーションに複数の引数を直接渡す代わりに、すべての引数を含む一意のinput型を一つ作成して渡します。

悪い例:

type Mutation {
  createPost(title: String!, content: String, authorId: ID!): Post
}

ここに新しい引数(例: tags: [String!])を追加すると、既存のクライアントとの互換性が壊れる可能性があります。

良い例:

input CreatePostInput {
  title: String!
  content: String
  authorId: ID!
  clientMutationId: String # クライアントがリクエストを識別するためのID(任意)
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

これで、CreatePostInputに新しいフィールドを追加しても、既存のクライアントは影響を受けません。これにより、非破壊的な変更(Non-breaking change)が可能になります。

2. ペイロード型の原則(Payload Type)

ミューテーションが単に作成・更新されたオブジェクトだけを返すのではなく、ミューテーションの結果をカプセル化する一意のpayload型を返すようにします。

良い例(続き):

type CreatePostPayload {
  post: Post
  errors: [UserError!]!
  clientMutationId: String
}

type UserError {
  message: String!
  field: [String!] # エラーが発生した入力フィールド
}

このペイロード構造は、以下のような情報を含むことができ、非常に柔軟です。

  • 変更されたデータ(post: クライアントが変更されたデータを再取得することなく、即座にUIを更新できます。
  • ユーザーレベルのエラー(errors: 「タイトルは5文字以上でなければなりません」のようなバリデーションエラーを構造化された形で渡すことができます。
  • クライアントID(clientMutationId: クライアントが送信したIDをそのまま返すことで、非同期環境でどのリクエストに対するレスポンスかを簡単に照合できます。

結論:良いスキーマは最高の投資

GraphQLスキーマ設計は、単にAPIのエンドポイントを定義する行為を超え、アプリケーションのデータフローと開発者体験全体を設計するプロセスです。クライアント中心の思考、明確な命名、拡張性、強力な型システムの活用、標準化されたページネーションおよびミューテーションのパターンは、堅牢で保守しやすいAPIを構築するための不可欠な礎です。

初期段階でスキーマ設計により多くの時間を投資することは、長期的には開発速度を向上させ、バグを減らし、フロントエンドとバックエンド間の円滑な協業を可能にする最高の投資となるでしょう。これらの原則が、あなたの次のGraphQLプロジェクトを成功に導く一助となることを願っています。