06 — Schema Linting & Governance
한 줄 답: GraphQL의 schema는 살아 있는 계약이다. 팀이 늘면 자연스럽게 동의어 필드(
getUser/findUser/userById)가 한 그래프에 공존하기 시작한다. 그것을 막는 도구가 linter(graphql-eslint, Apollo Rover, GraphQL Hive)와 컨벤션 — 단수/복수,ID!vsInt, mutation 명명, deprecation reason의 강제다.
Why — 왜 거버넌스가 별도 챕터인가
REST에서 경로 컨벤션 위반은 보통 별로 안 아프다 — /getUser와 /users/:id가 공존해도 그저 두 endpoint가 있을 뿐. 클라이언트는 몰라도 그만.
GraphQL은 다르다. 모든 type/field가 한 그래프에 공존한다. 그래서.
| 문제 | 결과 |
|---|---|
같은 개념에 동의어 필드 (getUser, findUser, user) | 클라이언트가 어느 걸 쓸지 매번 결정. 역사적 부채가 영원히 남음 |
단수/복수 불일치 (users vs userList) | type system이 학습 비용이 됨 |
ID vs Int 혼용 | 호환 깨짐 — Int였던 게 ID가 되면 클라이언트가 같이 깨짐 |
@deprecated에 reason 누락 | 사용자가 왜 deprecated인지 모름 — 마이그레이션 안 함 |
→ 즉 거버넌스 = “한 그래프에서 같은 개념을 같은 단어로 부르도록 강제하는 시스템”. 사람의 자율에 맡기면 반드시 흐트러진다. 도구로 CI에서 강제.
How — 어떻게 강제하나
1) Schema linter — 4가지 도구
| 도구 | 무엇을 검사하나 | 어디서 돌리나 |
|---|---|---|
graphql-eslint (The Guild) | 50+ 룰. naming, deprecation, unused fragment 등 | ESLint plugin → CI |
Apollo Rover rover graph lint | Apollo schema, breaking change 검사 포함 | CLI → CI |
| GraphQL Hive | schema check (breaking change · 사용량 분석) | SaaS / self-host |
graphql-schema-linter (CalmTech) | 1세대 linter, ESLint 호환 | CLI |
graphql-eslint 예시
npm install --save-dev @graphql-eslint/eslint-plugin// .eslintrc.cjs
module.exports = {
overrides: [{
files: ["*.graphql"],
parser: "@graphql-eslint/eslint-plugin",
plugins: ["@graphql-eslint"],
rules: {
"@graphql-eslint/naming-convention": ["error", {
types: "PascalCase",
FieldDefinition: "camelCase",
EnumValueDefinition: "UPPER_CASE",
}],
"@graphql-eslint/no-deprecated": "error",
"@graphql-eslint/require-deprecation-reason": "error",
"@graphql-eslint/require-description": "error",
"@graphql-eslint/no-typename-prefix": "error", // type User에 'user' prefix 금지
"@graphql-eslint/unique-fragment-name": "error",
"@graphql-eslint/no-anonymous-operations": "error",
},
}],
};→ CI에서 eslint '**/*.graphql' 한 줄로 전체 schema 점검.
2) 핵심 컨벤션 9가지
(a) 단수 vs 복수 — list는 항상 복수
# good
type Query {
user(id: ID!): User # 단수 — 단일 항목
users: [User!]! # 복수 — 리스트
}
# bad
type Query {
user: [User!]! # 단수 이름인데 list
userList: [User!]! # 명사+List 패턴 (영어식 어색)
}→ list가 항상 복수형 명사. 단수가 list라면 클라이언트가 type system에서 학습을 못 한다.
(b) ID! vs Int — 식별자는 항상 ID
# good
type User {
id: ID! # 식별자 = ID
age: Int # 수치 = Int
}
# bad
type User {
id: Int! # ID로 써야 함 — 향후 UUID/string 변경 시 break
}→ ID는 불투명한 식별자다. UUID, ULID, snowflake, autoincrement 정수 — 어떤 표현이든 받을 수 있게 문자열 직렬화. Int로 고정하면 type 변경이 break-change.
(c) Mutation 명명 — verb + noun
# good
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
# bad
type Mutation {
userCreate: ... # 어떤 회사는 이 컨벤션
newUser: ... # 동사 빠짐
saveUser: ... # create/update 구분 안 됨
}→ 컨벤션 둘 다 ok — createUser (verb-first, Apollo/GitHub) vs userCreate (noun-first, Relay/Shopify). 핵심은 한 그래프에서 일관성. eslint @graphql-eslint/naming-convention의 Mutation 옵션으로 강제.
(d) Input/Payload 타입 — 입력과 출력을 분리
# good — Relay 컨벤션
input CreateUserInput {
name: String!
email: String!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}→ Input 객체는 추가만으로 진화가 쉽다. Payload는 user 외에 error / clientMutationId / 메타를 나중에 추가 가능.
(e) Nullable 정책 — 기본은 nullable, 필수만 non-null
type User {
id: ID! # 시스템 invariant — non-null
name: String! # 가입 시 강제 — non-null
bio: String # 선택 — null 가능
email: String # 권한 따라 null
}→ User.email을 String!로 잡으면 비공개 사용자의 email을 null로 반환 못 함 — 전체 쿼리가 실패. Non-null은 신중.
(f) Enum vs String — 알려진 집합은 Enum
# good
enum OrderStatus { PENDING PAID SHIPPED DELIVERED CANCELLED }
type Order { status: OrderStatus! }
# bad
type Order { status: String! } # "shipped" / "Shipped" / "SHIPPED" 혼란→ Enum은 introspection으로 가능값이 노출. 클라이언트가 switch를 짤 수 있고, IDE가 자동완성.
(g) @deprecated — reason 필수
type User {
fullName: String! @deprecated(reason: "Use `name` instead. Removed 2026-09-01.")
name: String!
}→ deprecation은 클라이언트에게 보이는 changelog. reason 누락은 graphql-eslint의 require-deprecation-reason 룰로 CI에서 거절.
(h) Connection — list가 크면 Relay Connection
# Relay 표준
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge { node: User! cursor: String! }
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}→ 작은 list는 [User!]!로 충분하지만, 페이지네이션이 있는 list는 반드시 Connection. 일관성과 클라이언트 캐시 호환.
(i) Description — 모든 public 필드에
"""A registered user of the platform."""
type User {
"""Unique identifier (UUIDv7)."""
id: ID!
"""Display name (1~50 chars)."""
name: String!
}→ require-description 룰로 모든 public field에 description 강제. introspection으로 도구가 자동 문서를 만든다.
3) Breaking Change Detection — CI에서
@deprecated보다 더 위에 있는 보호장치 — breaking change를 PR 단계에서 잡기.
# Apollo Rover
rover graph check my-graph@prod --schema ./schema.graphql
# Hive
hive schema:check ./schema.graphql
# graphql-inspector (The Guild)
graphql-inspector diff old.graphql new.graphql3가지 변화 분류.
| 분류 | 예 | CI 동작 |
|---|---|---|
| Safe | 새 필드/타입 추가 | pass |
| Dangerous | 새 enum value, optional argument 추가 | warn |
| Breaking | 필드 삭제, 타입 변경, nullable → non-null | block |
4) Schema Registry + Usage Analytics
linter는 문법을 검사하지만, 실제 사용을 알 수 없다. 그래서 registry가 사용량 데이터를 함께 가진다.
| Registry | 사용량 추적 |
|---|---|
| Apollo Studio | 모든 operation의 operation name · 사용자 · 빈도 수집 |
| GraphQL Hive | usage 보고 — 어느 필드가 어느 클라이언트에 쓰이는지 |
| Stellate | edge cache hit + 사용량 |
이 데이터가 07(versioning)의 deprecation 결정에 직접 입력된다 — 0회 사용 필드는 안전하게 제거.
What — 구체 사양
graphql-eslint 핵심 룰 (50+ 중 중요한 것)
| 룰 | 무엇을 잡나 |
|---|---|
naming-convention | 타입/필드/enum의 case |
no-typename-prefix | type User { userName } 같은 중복 prefix |
require-description | description 누락 |
require-deprecation-reason | @deprecated reason 누락 |
require-deprecation-date | reason에 날짜 포함 강제 |
no-deprecated | client 측 — deprecated field 사용 금지 |
no-anonymous-operations | 익명 query 금지 (운영 추적용) |
selection-set-depth | client 측 — 깊은 쿼리 금지 |
unique-fragment-name | fragment 이름 중복 |
no-unreachable-types | 어떤 query/mutation에서도 도달 못 하는 type |
strict-id-in-types | type에 id: ID! 강제 (cache key 필수) |
relay-connection-types | Connection 패턴 강제 |
Apollo Rover의 schema check
# 변경 검사
rover graph check my-graph@prod --schema ./new.graphql
# 출력
# Compared schemas in operations from the last 7 days
# 3 operations affected by 1 breaking change:
# - Field `User.fullName` removed (used in: GetProfile, EditUser, SearchUsers)→ 사용량 + 변경을 결합 분석. breaking change에 영향받는 operation 목록까지 표시.
Hive의 usage-based check
# .github/workflows/schema-check.yml
- run: hive schema:check ./schema.graphql
env:
HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }}PR마다 schema diff를 production 사용량과 대조. 0회 사용 필드 제거는 safe로 분류, 1+회 사용 필드 제거는 breaking.
사실상 표준 컨벤션 — 회사별 비교
| 회사 | Mutation 명명 | ID 타입 | Connection |
|---|---|---|---|
| GitHub v4 | verbNoun (createIssue) | ID! | Relay Connection 표준 |
| Shopify Admin | nounVerb (productCreate) | ID! | Relay Connection 표준 |
| Apollo 권장 | verbNoun | ID! | Relay Connection 표준 |
| Stripe (REST 같은 모양) | nounVerb | String! (커스텀) | offset 페이지네이션 |
→ Relay Connection은 de facto 표준. Mutation 명명은 둘 다 ok.
도구 매트릭스
| 단계 | 도구 |
|---|---|
| schema 작성 | VS Code GraphQL plugin (syntax + autocomplete) |
| commit pre-hook | graphql-eslint (husky · lint-staged) |
| PR CI | rover graph check · hive schema:check |
| Runtime | Apollo Studio · Hive (usage) |
| Documentation | graphql-markdown, SpectaQL, Magidoc |
What-if — 잘못 이해하면
1) linter를 경고로만 두면
→ “나중에 고치자”가 되어 영원히 안 고친다. 새 컨벤션이 자랄 자리가 사라짐. 대응: 핵심 룰은 error로 CI block.
2) breaking change 검사를 수동으로 두면
→ 개발자가 영향 분석을 해서 통과시킴 — 실수로 빠뜨림.
대응: rover graph check를 PR 머지 조건에.
3) @deprecated reason을 내부 jargon으로 쓰면
→ "Refactored in #4521" 같은 reason은 클라이언트에 의미 없음.
대응: reason은 “대체 필드 + 제거 날짜 + 이유” 3요소.
4) 컨벤션을 문서로만 두면
→ 새 팀원이 문서를 읽지 않는다. 매 PR마다 리뷰어가 같은 지적. 대응: 컨벤션은 *코드(linter rule)*로 표현. 문서는 왜 그런 룰인지만.
5) Mutation 명명을 팀마다 다르게 두면
→ 한 그래프에 createUser와 userCreate가 공존.
대응: federation에서 각 subgraph마다 다른 컨벤션 허용은 위험 — 프로젝트 차원의 컨벤션.
6) description을 Korean으로 적으면
→ public API의 client codegen이 한국어 주석을 받아 영어 도구와 충돌. 대응: public API description은 영어. 내부 API는 자유.
7) Schema 사용량 데이터 없이 추측으로 deprecate하면
→ 사용 중인 필드를 제거해서 production이 깨짐. 대응: 사용량 추적 필수 — registry 도입.
Insight — 흥미로운 이야기
”GitHub의 ID는 Base64다”
GitHub v4의 모든 ID 필드는 MDQ6VXNlcjEyMzQ1 같은 Base64 인코딩 문자열이다. 디코드하면 04:User12345 — typename:internalId. typename이 ID 안에 들어 있다. 그래서 node(id: "MDQ6VXNlcjEyMzQ1") { ... on User { name } }이 직접 동작한다.
→ 교훈: ID!를 불투명하게 두면 후일 구조를 바꿔도 호환된다. Int로 박지 않은 결정이 수년 후의 자유를 만든다.
”Shopify가 productCreate를 고집하는 이유”
Shopify Admin API의 모든 mutation은 nounVerb 컨벤션이다 — productCreate, productUpdate, productDelete. 그 이유는 알파벳 정렬에서 관련 mutation이 한 자리에 모이게 하기 위해서다. GraphiQL의 자동완성에서 product를 치면 모든 product mutation이 한꺼번에 뜬다.
→ 교훈: 컨벤션 선택은 문법이 아니라 도구 UX에 영향. 어느 글자가 prefix가 되느냐가 개발자의 검색 경험을 결정.
”Apollo Rover의 breaking detection은 실 사용 데이터 위에 선다”
Apollo Studio는 production의 모든 쿼리를 수집한다. 그래서 rover graph check는 단순한 문법 diff가 아니라 *“이 필드를 지난 7일간 N명이 M번 썼다”*를 함께 본다. 그래서 *“제거해도 되는 필드”*를 데이터로 판단한다. 1회 사용이면 해당 사용자에게 마이그레이션 메일까지 자동 발송 (Studio 옵션).
→ 교훈: schema governance는 문법이 아니라 행동 데이터다. registry가 운영의 단일 진실이 된다.
”Spotify는 5000+ 타입을 한 그래프로 운영한다”
Spotify는 2020년 Backstage를 오픈소스화하면서 자기네 내부 GraphQL이 5000+ 타입, 50000+ 필드임을 공개했다. 이 규모를 컨벤션 없이 운영하는 건 불가능하다. 그들의 답은 graphql-eslint 30+ 룰 + Apollo Studio + 자체 schema review bot. 모든 PR이 4단계 게이트를 통과해야 했다 — lint, breaking check, naming review, performance check.
→ 교훈: schema의 규모는 컨벤션의 강도에 비례한다. 5천 타입은 문서로 운영 불가.
요약 + 다이어그램
큰 그래프는 컨벤션으로 산다. 9가지 핵심 컨벤션 — 단수/복수,
ID!강제, mutation 명명, Input/Payload, nullable 정책, Enum 사용, deprecation reason, Connection, description. 도구는 graphql-eslint(문법), rover/hive(breaking change), Studio/Hive(사용량) 3층. 다음 문서는 그 진화의 마지막 — 버전 없이 어떻게 그래프를 변경할 것인가.
다음 문서:
07-versioning-non.mdx—/v2/graphql은 안티패턴이다. 진화의 진짜 방식.