🔷 GraphQL7. 보안 & 거버넌스06 — Schema Linting & Governance

06 — Schema Linting & Governance

한 줄 답: GraphQL의 schema는 살아 있는 계약이다. 팀이 늘면 자연스럽게 동의어 필드(getUser / findUser / userById)가 한 그래프에 공존하기 시작한다. 그것을 막는 도구가 linter(graphql-eslint, Apollo Rover, GraphQL Hive)와 컨벤션 — 단수/복수, ID! vs Int, 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 lintApollo schema, breaking change 검사 포함CLI → CI
GraphQL Hiveschema 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 구분 안 됨
}

→ 컨벤션 둘 다 okcreateUser (verb-first, Apollo/GitHub) vs userCreate (noun-first, Relay/Shopify). 핵심은 한 그래프에서 일관성. eslint @graphql-eslint/naming-conventionMutation 옵션으로 강제.

(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.emailString!로 잡으면 비공개 사용자의 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) @deprecatedreason 필수

type User {
  fullName: String! @deprecated(reason: "Use `name` instead. Removed 2026-09-01.")
  name: String!
}

→ deprecation은 클라이언트에게 보이는 changelog. reason 누락은 graphql-eslintrequire-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.graphql

3가지 변화 분류.

분류CI 동작
Safe새 필드/타입 추가pass
Dangerous새 enum value, optional argument 추가warn
Breaking필드 삭제, 타입 변경, nullable → non-nullblock

4) Schema Registry + Usage Analytics

linter는 문법을 검사하지만, 실제 사용을 알 수 없다. 그래서 registry사용량 데이터함께 가진다.

Registry사용량 추적
Apollo Studio모든 operation의 operation name · 사용자 · 빈도 수집
GraphQL Hiveusage 보고 — 어느 필드가 어느 클라이언트에 쓰이는지
Stellateedge cache hit + 사용량

이 데이터가 07(versioning)의 deprecation 결정직접 입력된다 — 0회 사용 필드는 안전하게 제거.


What — 구체 사양

graphql-eslint 핵심 룰 (50+ 중 중요한 것)

무엇을 잡나
naming-convention타입/필드/enum의 case
no-typename-prefixtype User { userName } 같은 중복 prefix
require-descriptiondescription 누락
require-deprecation-reason@deprecated reason 누락
require-deprecation-datereason에 날짜 포함 강제
no-deprecatedclient 측 — deprecated field 사용 금지
no-anonymous-operations익명 query 금지 (운영 추적용)
selection-set-depthclient 측 — 깊은 쿼리 금지
unique-fragment-namefragment 이름 중복
no-unreachable-types어떤 query/mutation에서도 도달 못 하는 type
strict-id-in-typestype에 id: ID! 강제 (cache key 필수)
relay-connection-typesConnection 패턴 강제

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 diffproduction 사용량대조. 0회 사용 필드 제거는 safe로 분류, 1+회 사용 필드 제거는 breaking.

사실상 표준 컨벤션 — 회사별 비교

회사Mutation 명명ID 타입Connection
GitHub v4verbNoun (createIssue)ID!Relay Connection 표준
Shopify AdminnounVerb (productCreate)ID!Relay Connection 표준
Apollo 권장verbNounID!Relay Connection 표준
Stripe (REST 같은 모양)nounVerbString! (커스텀)offset 페이지네이션

Relay Connectionde facto 표준. Mutation 명명은 둘 다 ok.

도구 매트릭스

단계도구
schema 작성VS Code GraphQL plugin (syntax + autocomplete)
commit pre-hookgraphql-eslint (husky · lint-staged)
PR CIrover graph check · hive schema:check
RuntimeApollo Studio · Hive (usage)
Documentationgraphql-markdown, SpectaQL, Magidoc

What-if — 잘못 이해하면

1) linter를 경고로만 두면

→ “나중에 고치자”가 되어 영원히 안 고친다. 새 컨벤션이 자랄 자리가 사라짐. 대응: 핵심 룰은 errorCI block.

2) breaking change 검사를 수동으로 두면

→ 개발자가 영향 분석을 해서 통과시킴 — 실수로 빠뜨림. 대응: rover graph checkPR 머지 조건에.

3) @deprecated reason을 내부 jargon으로 쓰면

"Refactored in #4521" 같은 reason은 클라이언트에 의미 없음. 대응: reason은 “대체 필드 + 제거 날짜 + 이유” 3요소.

4) 컨벤션을 문서로만 두면

→ 새 팀원이 문서를 읽지 않는다. 매 PR마다 리뷰어가 같은 지적. 대응: 컨벤션은 *코드(linter rule)*로 표현. 문서는 왜 그런 룰인지만.

5) Mutation 명명을 팀마다 다르게 두면

→ 한 그래프에 createUseruserCreate공존. 대응: federation에서 각 subgraph마다 다른 컨벤션 허용은 위험프로젝트 차원의 컨벤션.

6) descriptionKorean으로 적으면

→ public API의 client codegen이 한국어 주석을 받아 영어 도구와 충돌. 대응: public API description은 영어. 내부 API는 자유.

7) Schema 사용량 데이터 없이 추측으로 deprecate하면

→ 사용 중인 필드를 제거해서 production이 깨짐. 대응: 사용량 추적 필수 — registry 도입.


Insight — 흥미로운 이야기

”GitHub의 ID는 Base64다”

GitHub v4의 모든 ID 필드는 MDQ6VXNlcjEyMzQ1 같은 Base64 인코딩 문자열이다. 디코드하면 04:User12345typename: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은 안티패턴이다. 진화의 진짜 방식.