🔷 GraphQL1. 스키마 & SDL07 · Schema-first vs Code-first

07 · Schema-first vs Code-first

이 문서가 답하는 질문: schema-first와 code-first의 차이는 무엇이고, Apollo/Yoga와 Pothos/Nexus/TypeGraphQL은 어떻게 다르며, 어느 팀에 어느 방식이 맞는가? 한 줄 답 (Pyramid Top): “schema-first는 SDL이 single source of truth고, code-first는 코드가 single source of truth이며 SDL은 산출물이다 — 어느 쪽이 우월하지 않고, 팀의 type system 의존도가 답을 정한다.”


Why — 같은 결과인데 왜 두 진영인가

GraphQL 서버를 만든다고 하자. 결과물은 동일하게 .graphql 스키마를 노출하는 HTTP endpoint. 가는 길이 두 가지다.

Path A — schema-first

# schema.graphql
type Query {
  user(id: ID!): User
}
 
type User {
  id: ID!
  name: String!
  posts: [Post!]!
}
 
type Post {
  id: ID!
  title: String!
}
// resolvers.ts
const resolvers = {
  Query: {
    user: (_, { id }, ctx) => ctx.db.user.findById(id),
  },
  User: {
    posts: (user, _, ctx) => ctx.db.post.findByAuthor(user.id),
  },
}

텍스트 SDL이 먼저, 그 다음 resolver 함수가 SDL에 매칭.

Path B — code-first

// schema.ts
const Post = builder.objectRef<PostRow>('Post').implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
  }),
})
 
const User = builder.objectRef<UserRow>('User').implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    posts: t.field({
      type: [Post],
      resolve: (user, _, ctx) => ctx.db.post.findByAuthor(user.id),
    }),
  }),
})
 
builder.queryType({
  fields: (t) => ({
    user: t.field({
      type: User,
      args: { id: t.arg.id({ required: true }) },
      resolve: (_, { id }, ctx) => ctx.db.user.findById(id),
    }),
  }),
})
 
const schema = builder.toSchema()    // .graphql은 빌드 산출물

TypeScript 코드가 먼저, SDL은 빌드해서 생성.

두 결과물(런타임 GraphQL endpoint)은 동일. 차이는 어디에서 진실이 시작되느냐.graphql 파일이냐 코드냐.


How — 두 진영의 동작 모델

schema-first — makeExecutableSchema

import { makeExecutableSchema } from '@graphql-tools/schema'
 
const typeDefs = /* GraphQL */ `
  type Query { user(id: ID!): User }
  type User { id: ID! name: String! posts: [Post!]! }
  type Post { id: ID! title: String! }
`
 
const resolvers = {
  Query: { user: (...) => ... },
  User:  { posts: (...) => ... },
}
 
export const schema = makeExecutableSchema({ typeDefs, resolvers })
  • typeDefs문자열 또는 .graphql 파일.
  • resolverstype 이름 + field 이름으로 SDL과 매칭.
  • 빌드 시 SDL 안의 모든 field에 대응 resolver가 있나 검증 — 없으면 default(fieldName property 그대로 반환).

대표 서버: Apollo Server, GraphQL Yoga, Mercurius(Fastify), Hot Chocolate(.NET schema-first 모드).

code-first — Pothos, Nexus, TypeGraphQL

각 라이브러리가 builder API를 제공 — type을 코드로 선언하고 .graphql은 빌드 산출물.

Pothos (구 GiraphQL)

import SchemaBuilder from '@pothos/core'
 
const builder = new SchemaBuilder<{ Context: MyContext }>({})
 
builder.objectType('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    posts: t.field({
      type: ['Post'],
      resolve: (user, _, ctx) => ctx.loaders.posts.load(user.id),
    }),
  }),
})
 
builder.queryType({
  fields: (t) => ({
    user: t.field({
      type: 'User',
      args: { id: t.arg.id({ required: true }) },
      resolve: (_, args, ctx) => ctx.db.user.findById(args.id),
    }),
  }),
})
 
export const schema = builder.toSchema()

가장 TypeScript-friendly. 타입 추론이 강하고 resolver의 args/return을 컴파일타임에 검증. Prisma/Drizzle 플러그인이 풍부.

Nexus

import { objectType, queryType, makeSchema } from 'nexus'
 
const User = objectType({
  name: 'User',
  definition(t) {
    t.id('id')
    t.string('name')
    t.list.field('posts', { type: 'Post' })
  },
})
 
const Query = queryType({
  definition(t) {
    t.field('user', {
      type: 'User',
      args: { id: idArg({ required: true }) },
      resolve: (_, { id }, ctx) => ctx.db.user.findById(id),
    })
  },
})
 
export const schema = makeSchema({ types: [User, Query] })

Prisma 팀이 만든 최초의 본격 code-first. .graphql 파일을 빌드 후 디스크에 출력하는 옵션이 기본 — PR 리뷰에서 schema diff를 볼 수 있다.

TypeGraphQL

import { Resolver, Query, ObjectType, Field, ID, Arg } from 'type-graphql'
 
@ObjectType()
class User {
  @Field(() => ID) id!: string
  @Field() name!: string
  @Field(() => [Post]) posts!: Post[]
}
 
@Resolver()
class UserResolver {
  @Query(() => User, { nullable: true })
  async user(@Arg('id', () => ID) id: string, @Ctx() ctx: Ctx) {
    return ctx.db.user.findById(id)
  }
}
 
export const schema = await buildSchema({ resolvers: [UserResolver] })

decorator 기반. NestJS와 짝. Java/C# 출신 개발자에게 친숙. experimental decorators에 의존하는 점이 호불호 갈림.


What — 비교 표

schema-first (Apollo·Yoga)code-first (Pothos·Nexus·TypeGraphQL)
SoT.graphql 파일TypeScript 코드
언어 종속무관 (Python·Go·Rust도 같은 SDL 사용)TS/JS 강결합
타입 안전성SDL과 resolver 매칭이 문자열 기반 → 누락 시 런타임 에러컴파일타임 검증
클라이언트 코드 생성SDL 그대로 GraphQL Code Generator빌드 후 SDL 추출 → 동일
PR 리뷰 경험SDL diff가 직접 보임코드 diff → SDL diff는 빌드해야 확인
러닝 커브낮음 (SDL 한 가지 문법)중간~높음 (라이브러리 builder API 학습)
순환 참조자연스러움 (텍스트)lazy thunk나 ref 필요
federation 호환명시적 (@key directive 텍스트로)라이브러리 플러그인 필요
DSL 확장성custom directive로 표현TS 코드로 자유롭게
대표 도구Apollo Server, GraphQL Yoga, MercuriusPothos, Nexus, TypeGraphQL, gqlgen(Go도 사실상 schema-first)

What — 어느 쪽이 어느 팀에 맞나

schema-first가 더 맞는 팀

  • 여러 언어의 서버를 운영: backend가 Python(Strawberry/Graphene), Go(gqlgen), TS(Apollo) 혼재 → SDL이 공통어.
  • API 설계자와 구현자가 다른 사람: 설계자가 .graphql을 적고 백엔드가 resolver 채움.
  • public API의 schema review가 PR의 일부: SDL diff를 직접 본다.
  • federation을 적극 사용: subgraph의 .graphql조립 단위.
  • type safety는 외부 코드 생성기가 책임: graphql-code-generator로 TS 타입 생성 후 수동 매칭해도 충분하다고 판단.

대표 사용처: GitHub, Shopify, Apollo 자체.

code-first가 더 맞는 팀

  • TypeScript only 백엔드: 어차피 코드가 진실이고 SDL은 배포 산출물.
  • ORM과의 깊은 통합: Prisma row 타입과 GraphQL object type을 컴파일타임에 매칭하고 싶다.
  • resolver의 args·return을 틀린 채 커밋 못 하게 강제.
  • DSL 자체를 자유롭게 확장: builder API에 조직 고유 패턴을 hook으로 추가.

대표 사용처: Prisma 자체, Linear, 다수의 TS-only SaaS.


What — 결정 트리

휴리스틱: 처음 GraphQL이고 팀이 TS만 쓴다Pothos. 여러 언어가 한 도메인을 공유schema-first. Public API이고 디자인 리뷰가 중요schema-first.


What-if — 두 방식이 한 레포에 섞일 때

// services/user/index.ts — code-first (Pothos)
builder.objectType('User', { ... })
 
// services/post/schema.graphql — schema-first
type Post { ... }

같은 레포에 SoT 두 개가 있는 상태. 시간이 가면:

  • .graphql은 일부 type만, 나머지는 introspection으로만 알 수 있음.
  • 코드 생성기가 어느 source를 봐야 하는지 헷갈림.
  • 신입이 어디서 type을 찾아야 할지 모름.

대응: 한 도메인에 한 방식. 절대 섞지 않는다. 마이그레이션 중이라면 기한을 정하고 한쪽으로 수렴.


What-if — 자주 틀리는 결정

함정 1) “TypeScript 쓰니까 무조건 code-first”

TS 백엔드라도 팀이 SDL을 PR에서 직접 보고 싶어한다면 schema-first가 옳다. 컴파일타임 타입SDL 리뷰 경험의 trade-off.

함정 2) “여러 언어 백엔드면 무조건 schema-first”

여러 언어라도 각 서비스의 schema가 독립적이면 code-first도 가능 — 다만 federation에서는 gateway가 보는 .graphql이 필요하므로 어느 쪽으로든 결국 SDL이 산출되어야 한다.

함정 3) Code-first에서 SDL을 디스크에 출력하지 않음

const schema = builder.toSchema()

만 하고 .graphql 파일을 빌드 후 디스크에 안 출력하면:

  • 클라이언트 코드 생성기introspection을 실시간으로 받아와야 함.
  • Git diff에서 schema 변경이 안 보임.
  • PR 리뷰에서 breaking change 감지 어려움.

대응: code-first 라이브러리들의 outputs 옵션으로 .graphql 파일도 같이 커밋.

// Nexus
makeSchema({
  outputs: {
    schema: __dirname + '/schema.graphql',
    typegen: __dirname + '/nexus-typegen.ts',
  },
})

함정 4) Schema-first에서 resolver 누락을 런타임에 발견

type User {
  id: ID!
  name: String!
  bio: String          # ← 새로 추가
}
const resolvers = {
  User: { /* bio 없음 */ }
}

위 코드는 컴파일 통과. 런타임에 bio field 요청이 오면 default resolverparent.bio를 반환 — DB row에 bio가 있으면 작동, 없으면 null 또는 런타임 에러.

대응 1: GraphQL Code Generator의 resolver type 생성Resolvers<Context> 타입을 강제하면 누락 시 컴파일 에러. 대응 2: graphql-tools의 inheritResolversFromInterfaces + lint 룰.

함정 5) Type 생성을 빌드에 안 묶음

code-first의 typegen(Nexus의 .d.ts, Pothos의 plugin 출력)은 코드가 바뀔 때마다 재생성되어야 한다. 빌드에 안 묶으면 타입이 정체되어 “분명 코드는 맞는데 컴파일 에러”가 생긴다.

대응: predev·prebuild 스크립트에 schema 빌드 + typegen.


Insight — 흥미로운 이야기

“GraphQL의 첫 구현은 code-first였다”

2015년 GraphQL이 공개됐을 때, SDL 자체가 spec에 없었다. graphql-js(첫 reference 구현)는 JavaScript builder API로만 스키마를 정의했다 — new GraphQLObjectType({ name, fields }). SDL은 2016년 RFC로 추가되어 2018년에 spec에 정식 포함됐다. 즉 code-first가 원조schema-first가 후발이다. 그러나 공유의 편의성 때문에 schema-first가 대중적이 됐고, 코드 안전성 때문에 Pothos 같은 모던 code-first재부상했다 — 순환의 양면.

“Pothos가 Nexus를 추월한 이유는 Prisma 통합

Nexus(2018, Prisma 팀)는 한때 code-first의 표준이었다. 그러나 Prisma 팀이 Nexus 개발을 사실상 중단(2021)하면서 커뮤니티가 흔들렸다. Pothos(원래 GiraphQL, 2019, Michael Hayes)가 공식 Prisma 플러그인을 발표하면서 de facto 후계자가 됐다. Pothos의 plugin 아키텍처는 Nexus보다 확장성 좋고 타입 추론 강함. 2024년 현재, 새 TS GraphQL 프로젝트가 code-first를 선택하면 대부분 Pothos다.

“GitHub은 schema-first고 Shopify는 schema-first고 Apollo도 schema-first다”

공개 API를 운영하는 대형 GraphQL 사용처는 거의 모두 schema-first. 이유:

  1. Public schema review가 디자인 과정의 핵심 — SDL diff가 PR description의 일부.
  2. Multi-language client SDK 생성 — SDL이 진실이어야 언어별 SDK가 일관됨.
  3. Federation — subgraph의 경계는 SDL 파일 단위.

반면 내부 도구·SaaS 백엔드Pothos·Nexus 등 code-first 채택 비율이 더 높다 — 팀이 작고 TS-only인 경우.

“gqlgen은 schema-first지만 Go 타입을 생성한다 — 두 진영의 하이브리드”

Go의 사실상 표준 GraphQL 서버 gqlgen.graphql을 입력으로 받아 Go struct와 resolver interface를 자동 생성. 개발자는 interface 구현만 작성컴파일타임 타입 안전성을 schema-first에서 얻는다. 이게 제3의 길SDL이 SoT이지만 언어 타입을 codegen. 다음 세대 GraphQL TS 도구가 이 방향으로 가고 있다(GraphQL Codegen의 server preset).


요약 + Mermaid

요약: schema-first는 SDL이 SoT고, code-first는 코드가 SoT다. 어느 쪽이 우월하지 않고 — 팀 언어 다양성·SDL 리뷰 중요도·type safety 요구가 결정한다. 새 TS 프로젝트가 code-first면 Pothos가 첫번째 선택. 여러 언어·공개 API면 schema-first. 챕터 끝 — 이 7개 문서를 다 읽었으면 GraphQL의 계약은 손에 잡힌다. 다음 챕터(02-execution-resolvers)는 이 계약을 어떻게 평가하나를 다룬다.