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 프로젝트가 성공적으로 구축되기를 바랍니다.


0 개의 댓글:

Post a Comment