본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 2장: 스키마 설계 패턴
2026년 2월 8일·웹 개발·

2장: 스키마 설계 패턴

GraphQL 스키마 설계의 두 가지 접근법과 실전 타입 설계 패턴을 다룹니다. Node 인터페이스, Relay 커서 페이지네이션, 유니온/인터페이스 등 프로덕션 수준의 스키마 설계 원칙을 학습합니다.

13분731자8개 섹션
graphqlapi-designfrontend
공유
graphql-architecture2 / 11
1234567891011
이전1장: GraphQL의 등장과 핵심 가치다음3장: Resolver 구현과 데이터 로딩

학습 목표

  • 스키마 퍼스트와 코드 퍼스트 접근법의 장단점을 비교한다
  • Node 인터페이스와 글로벌 ID 패턴을 이해한다
  • Relay 스타일 커서 페이지네이션(Connection 패턴)을 구현할 수 있다
  • 유니온, 인터페이스, 열거형을 적절히 활용하는 방법을 익힌다

스키마 퍼스트 vs 코드 퍼스트

GraphQL 스키마를 정의하는 방법은 크게 두 가지로 나뉩니다.

스키마 퍼스트(Schema-first)

.graphql 파일에 SDL(Schema Definition Language)로 스키마를 먼저 작성한 뒤, 리졸버를 구현합니다.

schema.graphql
graphql
type Query {
  user(id: ID!): User
  posts(first: Int!, after: String): PostConnection!
}
 
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
resolvers.ts
typescript
import type { Resolvers } from './generated/types';
 
const resolvers: Resolvers = {
  Query: {
    user: (_parent, args, context) => {
      return context.dataSources.users.findById(args.id);
    },
  },
};

장점: 스키마가 API 계약서 역할을 하며, 프런트엔드/백엔드 팀 간 소통이 명확합니다. SDL은 비개발자도 읽기 쉽습니다.

단점: 스키마와 리졸버 사이의 타입 동기화가 수동으로 이루어져야 합니다. GraphQL Code Generator로 이 문제를 완화할 수 있지만 추가 빌드 단계가 필요합니다.

코드 퍼스트(Code-first)

TypeScript 코드에서 스키마를 프로그래밍 방식으로 정의합니다. Pothos나 Nexus 같은 빌더 라이브러리를 사용합니다.

schema-pothos.ts
typescript
import SchemaBuilder from '@pothos/core';
 
const builder = new SchemaBuilder({});
 
builder.queryType({
  fields: (t) => ({
    user: t.field({
      type: UserType,
      args: {
        id: t.arg.id({ required: true }),
      },
      resolve: (_parent, args, context) => {
        return context.dataSources.users.findById(String(args.id));
      },
    }),
  }),
});
 
const UserType = builder.objectType('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    email: t.exposeString('email'),
    posts: t.field({
      type: [PostType],
      resolve: (user, _args, context) => {
        return context.dataSources.posts.findByAuthorId(user.id);
      },
    }),
  }),
});

장점: 타입 안전성이 컴파일 타임에 보장됩니다. 리졸버와 스키마가 한 곳에 있어 동기화 문제가 없습니다.

단점: SDL보다 코드가 장황합니다. 스키마의 전체 모습을 한눈에 파악하기 어려울 수 있습니다.

Tip

2026년 현재 트렌드는 코드 퍼스트로 기울고 있습니다. 특히 Pothos는 TypeScript의 타입 추론을 극대화하여 별도 코드 생성 없이도 완전한 타입 안전성을 제공합니다. 다만 팀의 기존 워크플로와 선호도를 고려하여 결정해야 합니다.


Node 인터페이스와 글로벌 ID

대규모 GraphQL API에서는 Node 인터페이스를 통해 모든 엔티티를 일관되게 조회할 수 있는 구조를 갖추는 것이 좋습니다. 이 패턴은 Relay 사양에서 유래했지만, Relay를 사용하지 않더라도 유용합니다.

Node 인터페이스 정의

node-interface.graphql
graphql
interface Node {
  id: ID!
}
 
type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
}
 
type User implements Node {
  id: ID!
  name: String!
  email: String!
}
 
type Post implements Node {
  id: ID!
  title: String!
  content: String!
}

글로벌 ID 인코딩

글로벌 ID는 타입 정보와 데이터베이스 ID를 결합하여 시스템 전체에서 고유한 식별자를 만듭니다.

global-id.ts
typescript
function toGlobalId(typeName: string, id: string): string {
  return Buffer.from(`${typeName}:${id}`).toString('base64');
}
 
function fromGlobalId(globalId: string): { typeName: string; id: string } {
  const decoded = Buffer.from(globalId, 'base64').toString('utf8');
  const [typeName, id] = decoded.split(':');
  return { typeName, id };
}
 
// 사용 예시
toGlobalId('User', '42');      // "VXNlcjo0Mg=="
fromGlobalId('VXNlcjo0Mg==');  // { typeName: 'User', id: '42' }

Node 리졸버 구현

node-resolver.ts
typescript
const resolvers = {
  Query: {
    node: async (_parent: unknown, args: { id: string }, context: Context) => {
      const { typeName, id } = fromGlobalId(args.id);
      
      switch (typeName) {
        case 'User':
          return context.dataSources.users.findById(id);
        case 'Post':
          return context.dataSources.posts.findById(id);
        default:
          return null;
      }
    },
  },
  Node: {
    __resolveType(obj: { __typename?: string }) {
      return obj.__typename ?? null;
    },
  },
};

글로벌 ID의 핵심 이점은 클라이언트 캐싱입니다. Apollo Client나 Relay는 글로벌 ID를 캐시 키로 사용하여 동일 엔티티의 중복 요청을 방지합니다.


Connection 패턴 (커서 페이지네이션)

목록 데이터를 다루는 GraphQL API에서 가장 널리 채택된 페이지네이션 패턴은 Relay 스타일 Connection 패턴입니다.

왜 오프셋 페이지네이션이 아닌가

오프셋 기반 페이지네이션(page, limit)은 구현이 단순하지만 몇 가지 문제가 있습니다.

  • 데이터 삽입/삭제 시 페이지 경계가 어긋남
  • 대용량 데이터에서 OFFSET이 성능 저하를 유발
  • 무한 스크롤 UI와 궁합이 맞지 않음

커서 기반 페이지네이션은 이 문제를 해결합니다.

Connection 스키마 구조

connection.graphql
graphql
type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
  ): PostConnection!
}
 
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
 
type PostEdge {
  cursor: String!
  node: Post!
}
 
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Connection 리졸버 구현

connection-resolver.ts
typescript
interface PaginationArgs {
  first?: number;
  after?: string;
  last?: number;
  before?: string;
}
 
async function resolvePostConnection(
  args: PaginationArgs,
  context: Context,
): Promise<PostConnection> {
  const limit = args.first ?? args.last ?? 20;
  const cursor = args.after ?? args.before;
  
  const decodedCursor = cursor
    ? Buffer.from(cursor, 'base64').toString('utf8')
    : undefined;
 
  const posts = await context.dataSources.posts.findPaginated({
    limit: limit + 1, // 다음 페이지 존재 여부 확인용
    cursor: decodedCursor,
    direction: args.last ? 'backward' : 'forward',
  });
 
  const hasMore = posts.length > limit;
  const slicedPosts = hasMore ? posts.slice(0, limit) : posts;
 
  const edges = slicedPosts.map((post) => ({
    cursor: Buffer.from(post.id).toString('base64'),
    node: post,
  }));
 
  return {
    edges,
    pageInfo: {
      hasNextPage: args.first ? hasMore : false,
      hasPreviousPage: args.last ? hasMore : false,
      startCursor: edges[0]?.cursor ?? null,
      endCursor: edges[edges.length - 1]?.cursor ?? null,
    },
    totalCount: await context.dataSources.posts.count(),
  };
}
Info

커서는 불투명(opaque) 문자열이어야 합니다. 클라이언트가 커서의 내부 구조에 의존하면 안 됩니다. Base64 인코딩은 이 불투명성을 보장하는 일반적인 방법입니다.


입력 타입(Input Types)

Mutation에서 복잡한 입력 데이터를 받을 때는 Input 타입을 사용합니다.

input-types.graphql
graphql
input CreatePostInput {
  title: String!
  content: String!
  categoryId: ID!
  tags: [String!]
  publishAt: DateTime
}
 
input UpdatePostInput {
  title: String
  content: String
  categoryId: ID
  tags: [String!]
}
 
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
}
 
type CreatePostPayload {
  post: Post
  errors: [UserError!]!
}
 
type UserError {
  field: String!
  message: String!
}

Mutation 응답에 Payload 타입을 사용하는 것이 모범 사례입니다. 성공 시 결과 데이터를, 실패 시 구조화된 에러 목록을 반환할 수 있습니다.


열거형, 유니온, 인터페이스

열거형(Enum)

유한한 선택지를 표현할 때 사용합니다.

enums.graphql
graphql
enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}
 
enum SortOrder {
  ASC
  DESC
}
 
input PostFilter {
  status: PostStatus
  sortBy: PostSortField
  sortOrder: SortOrder
}
 
enum PostSortField {
  CREATED_AT
  UPDATED_AT
  TITLE
  VIEW_COUNT
}

유니온(Union)

서로 다른 타입을 하나의 필드에서 반환해야 할 때 사용합니다.

union.graphql
graphql
union SearchResult = Post | User | Comment
 
type Query {
  search(query: String!): [SearchResult!]!
}
union-resolver.ts
typescript
const resolvers = {
  SearchResult: {
    __resolveType(obj: { __typename: string }) {
      return obj.__typename;
    },
  },
  Query: {
    search: async (_parent: unknown, args: { query: string }, context: Context) => {
      const [posts, users, comments] = await Promise.all([
        context.dataSources.posts.search(args.query),
        context.dataSources.users.search(args.query),
        context.dataSources.comments.search(args.query),
      ]);
      return [
        ...posts.map((p) => ({ ...p, __typename: 'Post' as const })),
        ...users.map((u) => ({ ...u, __typename: 'User' as const })),
        ...comments.map((c) => ({ ...c, __typename: 'Comment' as const })),
      ];
    },
  },
};

인터페이스(Interface)

공통 필드를 공유하는 타입들을 추상화합니다.

interface.graphql
graphql
interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}
 
type Post implements Node & Timestamped {
  id: ID!
  title: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}
 
type Comment implements Node & Timestamped {
  id: ID!
  body: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

네이밍 규칙

일관된 네이밍은 스키마의 가독성과 유지보수성을 크게 향상시킵니다.

대상규칙예시
타입PascalCaseUserProfile, PostConnection
필드camelCasefirstName, createdAt
열거형 값SCREAMING_SNAKE_CASEPUBLISHED, IN_PROGRESS
Input 타입동사 + 명사 + InputCreatePostInput, UpdateUserInput
Payload 타입동사 + 명사 + PayloadCreatePostPayload
인자camelCasefirst, after, sortBy
Tip

필드명은 약어를 피하고 의미를 명확히 드러내야 합니다. cnt보다 count, desc보다 description이 좋습니다. 스키마는 API의 인터페이스이자 문서입니다.


정리

이번 장에서는 GraphQL 스키마 설계의 핵심 패턴을 살펴보았습니다. 스키마 퍼스트와 코드 퍼스트 접근법 각각의 장단점을 비교했고, Node 인터페이스를 통한 일관된 엔티티 조회, Connection 패턴을 통한 커서 기반 페이지네이션, 그리고 입력 타입과 다형성 타입의 활용법을 학습했습니다.

좋은 스키마 설계는 GraphQL API의 성공을 좌우합니다. 클라이언트 관점에서 직관적이고, 확장에 열려 있으며, 일관된 규칙을 따르는 스키마를 설계하는 것이 핵심입니다.

다음 장 미리보기

3장에서는 스키마를 실제로 동작하게 만드는 리졸버 구현을 다룹니다. 리졸버 체인의 실행 흐름, 컨텍스트 설계, 데이터 소스 추상화 패턴, 그리고 Apollo Server와 GraphQL Yoga에서의 구체적인 구현 방법을 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

3장: Resolver 구현과 데이터 로딩

GraphQL 리졸버 체인의 실행 흐름과 컨텍스트 설계를 이해합니다. 데이터 소스 추상화, 부모-자식 리졸버 관계, Apollo Server와 GraphQL Yoga에서의 실전 구현 패턴을 다룹니다.

2026년 2월 10일·14분
웹 개발

1장: GraphQL의 등장과 핵심 가치

REST API의 한계인 오버페칭과 언더페칭 문제를 분석하고, GraphQL의 타입 시스템, 스키마, 리졸버 등 핵심 개념을 소개합니다. 2026년 GraphQL 생태계 현황과 도입 판단 기준을 다룹니다.

2026년 2월 6일·14분
웹 개발

4장: N+1 문제와 DataLoader 최적화

GraphQL에서 가장 흔한 성능 문제인 N+1 문제의 원인을 분석하고, DataLoader를 통한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한 전략도 다룹니다.

2026년 2월 12일·13분
이전 글1장: GraphQL의 등장과 핵심 가치
다음 글3장: Resolver 구현과 데이터 로딩

댓글

목차

약 13분 남음
  • 학습 목표
  • 스키마 퍼스트 vs 코드 퍼스트
    • 스키마 퍼스트(Schema-first)
    • 코드 퍼스트(Code-first)
  • Node 인터페이스와 글로벌 ID
    • Node 인터페이스 정의
    • 글로벌 ID 인코딩
    • Node 리졸버 구현
  • Connection 패턴 (커서 페이지네이션)
    • 왜 오프셋 페이지네이션이 아닌가
    • Connection 스키마 구조
    • Connection 리졸버 구현
  • 입력 타입(Input Types)
  • 열거형, 유니온, 인터페이스
    • 열거형(Enum)
    • 유니온(Union)
    • 인터페이스(Interface)
  • 네이밍 규칙
  • 정리
    • 다음 장 미리보기