07 — Versioning-Non

한 줄 답: GraphQL은 버전이 없다. /v2/graphql을 만드는 건 안티패턴이다. 같은 그래프 안에서 필드 추가는 항상 안전하고, 필드 제거는 사용량 분석 후 @deprecated경고 후 제거한다. 진화의 책임이 클라이언트가 아닌 빌더에게 옮겨진다는 것이 GraphQL versioning-non의 진짜 의미.


Why — “GraphQL은 버전이 없다”의 진짜 의미

GraphQL 공식 사이트의 첫 페이지에는 “Versioning” 섹션이 있다.

“Why do most APIs version? When there’s limited control over the data that’s returned from an API endpoint, any change can be considered a breaking change, and breaking changes require a new version. If adding new features to an API requires a new version, then a tradeoff emerges between making useful enhancements and maintaining disparate versions. In contrast, GraphQL only returns the data that’s explicitly requested, so new capabilities can be added via new types and new fields on those types without creating a breaking change. This has led to a common practice of always avoiding breaking changes and serving a versionless API.” — graphql.org/learn/best-practices/#versioning

핵심은 “requested fields only”. REST는 서버가 응답 모양을 결정하므로 필드 추가도 break가 될 수 있다. GraphQL은 클라이언트가 요청한 것만 받으므로 추가는 항상 안전하다.


How — 어떻게 버전 없이 진화하나

1) 3가지 변경 분류

스키마 변경은 3가지로 나뉜다.

분류안전성CI
Additive (추가)새 타입, 새 필드, 새 mutation, optional arg 추가항상 safepass
Dangerousenum value 추가, nullable → 더 nullable, deprecation조건부 safewarn
Breaking필드 제거, 타입 제거, non-null 변경, 인자 의미 변경위험block

Additive — 매일 해도 되는 것

# 이전
type User { id: ID! name: String! }
 
# 이후 (필드 추가)
type User {
  id: ID!
  name: String!
  avatar: String          # 신규 — 기존 클라이언트는 모르므로 영향 0
  preferences: UserPrefs  # 신규 type 함께 추가
}
type UserPrefs { theme: String! }

→ 기존 클라이언트는 avatar요청하지 않았으므로 응답에 없다. 영향 0. 새 클라이언트만 활용.

Dangerous — 주의가 필요한 것

# 위험 1 — enum value 추가
enum OrderStatus { PENDING PAID SHIPPED DELIVERED CANCELLED }
# 추가
enum OrderStatus { PENDING PAID SHIPPED DELIVERED CANCELLED REFUNDED }
# 클라이언트가 exhaustive switch를 짰다면 — 새 enum 값이 모름

→ 클라이언트가 exhaustive switch를 짜고 default가 없으면 새 enum 값에서 crash. 그래서 enum 추가는 dangerous.

# 위험 2 — optional argument 추가
type Query {
  search(query: String!): SearchResult!
}
# 추가
type Query {
  search(query: String!, filter: SearchFilter): SearchResult!
}
# 클라이언트는 기존처럼 호출 가능하므로 safe — 하지만 의미가 바뀐다면 위험

Breaking — 절대 직접 하면 안 되는 것

# 이전
type User {
  id: ID!
  name: String!
  fullName: String!
}
 
# 이후 — fullName 제거
type User {
  id: ID!
  name: String!
}
# 클라이언트의 `{ user { fullName } }`이 *바로 fail*

→ 이걸 바로 하면 모든 기존 클라이언트가 동시에 깨진다. @deprecated를 거쳐서 단계적으로.

2) @deprecated — 진화의 단일 도구

GraphQL spec에 공식 directive로 정의된 것은 단 두 개다.

directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
directive @deprecated(reason: String = "No longer supported") on
  FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION

@deprecated4가지 적용 위치.

type User {
  # 1) FIELD_DEFINITION
  fullName: String! @deprecated(reason: "Use `name`. Removed 2026-09-01.")
 
  # 2) ARGUMENT_DEFINITION
  posts(
    after: String
    cursor: String @deprecated(reason: "Use `after`. Removed 2026-09-01.")
  ): [Post!]!
}
 
enum OrderStatus {
  PENDING
  PAID
  SHIPPED
  # 3) ENUM_VALUE
  WAITING @deprecated(reason: "Renamed to PENDING. Removed 2026-09-01.")
}
 
input UserFilter {
  name: String
  # 4) INPUT_FIELD_DEFINITION
  fullName: String @deprecated(reason: "Use `name`. Removed 2026-09-01.")
}

type 자체에는 @deprecated못 붙는다 (Federation 2의 @inaccessible 등 별도 directive로 처리).

3) Deprecation의 5단계 라이프사이클

각 단계의 디테일.

단계기간활동
1) 새 필드즉시additive — safe하게 추가
2) 옛 필드 deprecated즉시reason에 대체 필드제거 일자 명시
3) 사용량 추적1~6개월Apollo Studio / Hive에서 operation별 사용 빈도
4) 사용 = 0 검증마이그레이션 기간 종료 시사용 중 클라이언트에 직접 공지
5) 제거사용 0 확인 후breaking change — schema에서 삭제

이 5단계 중 클라이언트가 부담하는 건 0이다. 클라이언트는 @deprecated 경고만 보고 천천히 마이그레이션한다.

4) @deprecated클라이언트에 보이는 changelog

이게 이 챕터 강조의 핵심이다. @deprecated코드 안의 changelog가 아니다 — introspection으로 클라이언트에 보인다.

# GraphiQL에서 fullName을 자동완성하면
# 자동완성 옆에 ⚠ "Deprecated: Use `name`. Removed 2026-09-01." 가 표시됨
// codegen이 deprecated field에 JSDoc 주석을 자동 추가
/** @deprecated Use `name`. Removed 2026-09-01. */
fullName: string;

→ 클라이언트는 IDE/codegen 단계에서 deprecation을 눈으로 본다. 마이그레이션이 passive하게 일어난다. REST의 changelog 문서를 읽어야 알 수 있는 것과 정반대.

5) “추가는 안전” — 왜 진짜로 안전한가

type User { id: ID! name: String! email: String }

이 type을 한 새 필드 bio: String을 추가하면 왜 안전한가?

  1. 기존 쿼리bio를 요청 안 함 → 응답에 없음 → 클라이언트 모름.
  2. 기존 fragmentbio를 요청 안 함 → 영향 없음.
  3. 기존 codegenbio모름 → 타입에 없음 → 컴파일 영향 없음.
  4. introspection새 필드를 표시 → 새 클라이언트만 활용.

→ 즉 요청만 응답하는 GraphQL의 invariant가 추가의 안전성을 수학적으로 보장한다.

6) “제거는 사용량 분석 후” — 실용적 절차

# Apollo Studio
rover graph check my-graph@prod --schema ./schema.graphql
# 출력: "User.fullName removed — affects 3 operations:
#         - GetProfile (used by mobile-app, 50K calls/day)
#         - EditUser (used by web-app, 10K calls/day)"
 
# Hive
hive schema:check --service users ./schema.graphql

→ 0회 사용이면 safe, 1+회면 클라이언트 직접 컨택 후 제거. 일부 회사는 완전 자동화 — Apollo Studio가 deprecated field 사용자에게 자동 알림.

7) “@inaccessible” — Federation의 부드러운 제거

Apollo Federation 2는 @inaccessible directive를 추가했다.

type User {
  id: ID!
  internalField: String @inaccessible  # public graph에서 숨김, subgraph 안에서만 접근 가능
}

@deprecated“있지만 쓰지 말라”, @inaccessible“있지만 안 보임”. Federation 환경에서 내부용 필드public graph에서 숨기는 도구.


What — 구체 사양

@deprecated의 spec 정의

@deprecatedOctober 2021 spec에 공식 directive로 박혀 있다. 인자: reason: String = "No longer supported" 적용 가능: FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION Introspection에서 __Type.fields(includeDeprecated: Boolean), __EnumValue.isDeprecated 등으로 노출됨.

Reason 컨벤션 — 3요소

좋은 reason은 3가지를 포함한다.

요소
대체 필드Use \name` instead.`
제거 날짜Removed 2026-09-01.
이유 (선택)\fullName` was ambiguous for non-Latin names.`

조합.

fullName: String! @deprecated(
  reason: "Use `name` instead. Removed 2026-09-01. `fullName` was ambiguous for non-Latin names."
)

graphql-eslintrequire-deprecation-date 룰은 날짜 포함CI에서 강제.

”버전이 정 필요할 때” — 안티패턴이지만 존재하는 패턴

잘못된 접근더 나은 접근
POST /v2/graphql 새 endpointUser 옆에 UserV2 type 추가, 점진 마이그레이션
헤더로 버전 (X-API-Version: 2)optional argument로 모드 전환 (user(id: ID!, mode: UserMode = V1): User)
Schema 전체 forkFederation으로 subgraph 분리 — 각자 진화

→ 모든 경우 진화 책임이 빌더에게 있다. 클라이언트는 현재 그래프만 본다.

Breaking change를 불가피하게 해야 할 때 — 도구

도구무엇을
Apollo Studio Schema Checks사용 중 operation을 목록화
Hive Schema Change Tracker같은
자동 마이그레이션 메일Apollo / Hive 옵션
Subgraph 분리 (Federation)새 subgraph에서 새 schema, 기존 subgraph는 유지

Spec에서 비version임을 강제하지는 않는다

GraphQL spec은 versioning 금지강제하지 않는다. /v2/graphqlspec 위반은 아니다. 다만 생태계의 합의가 *“하지 마라”*다. spec은 @deprecated주고, 운영 모델은 생태계가 합의했다.


What-if — 잘못 이해하면

1) “GraphQL은 버전이 없으니 schema는 안 진화한다”라고 보면

→ 잘못된 결론. 진화는 더 자주 일어난다 — additive가 매일 가능하므로. 진화의 빈도는 더 높고, 비용은 더 낮다.

2) @deprecated만 붙이고 제거 안 하면

→ 그래프가 부채로 가득. 새 사용자가 무엇이 권장인지 혼란. 결국 둘 다 사용됨. 대응: 제거 일자를 reason에 명시 + CI 강제.

3) @deprecated 없이 바로 제거하면

→ 모든 클라이언트가 예고 없이 깨진다. SLA 위반. 대응: 최소 1~3개월 deprecation 기간 + 사용량 0 확인.

4) 사용량을 추적 안 한 채 제거하면

모르고 있는 클라이언트가 깨진다. 책임이 빌더에게 옮겨졌다는 게 책임이 면제되는 게 아니다. 대응: 사용량 추적은 필수 인프라. registry 도입.

5) Enum 추가가 safe라고 보면

→ 클라이언트 exhaustive switch가 crash. 대응: 클라이언트 측에 default case 의무화 (linter rule). GraphQL spec은 enum 추가를 dangerous로 분류.

6) /v2/graphql깨끗하게 다시 시작하려 하면

클라이언트가 둘 다 유지해야 함 — 마이그레이션 부담이 클라이언트에 옮겨짐. GraphQL 운영 모델 자체를 깬다. 대응: 같은 그래프 안에서 type 옆에 새 type 추가, 점진 마이그레이션.

7) Public API에서 deprecation date조용히 미루면

→ 신뢰 문제. 클라이언트가 마이그레이션 안 함. 대응: deprecation date는 공개 약속. 미루더라도 공지.


Insight — 흥미로운 이야기

”GitHub은 GraphQL API v4를 5년간 v4로 운영 중”

GitHub GraphQL API는 2016년 v4로 출시됐다 — REST API v3 다음 버전이라는 뜻. 그 이후 5년 + α 동안 여전히 v4다. 그동안 수백 개의 필드가 추가됐고, 수십 개가 deprecated됐고, 몇 개가 제거됐다. 모두 같은 endpoint에서. GitHub는 v5를 만들 이유가 없다 — 같은 그래프 안에서 진화가 충분했다.

→ 교훈: GraphQL의 versionless 약속이 5년+ 검증된 사례. v4라는 이름이 마지막 versioning 잔재다.

”Facebook은 Server-driven으로 한 단계 더 갔다”

Facebook 내부의 GraphQL은 deprecation조차 자동화돼 있다. 모든 deprecated 필드는 internal sunset framework에 등록되어 6주 후 자동 제거. 클라이언트는 제거 1주일 전 자동 메일 받음. 2주 전 CI block. 제거 당일 응답이 null + warning extension. 운영 비용이 낮을 수밖에 없다.

→ 교훈: deprecation은 문화가 아니라 자동화가 답. Apollo Studio / Hive가 그걸 상품화하는 중.

”Apollo의 @tag(name: \"public\") directive”

Apollo Federation 2는 그래프 노출 범위공식 directive로 표현한다.

type User @tag(name: "public") {
  id: ID!
  email: String @tag(name: "internal")
}

→ 같은 graph에서 public은 외부 contour에 노출, internalgateway에서 잘림. 즉 *“여러 버전”*이 아니라 “여러 뷰”. 같은 그래프, 다른 노출.

→ 교훈: versioning의 진짜 욕구“여러 사용자에게 다른 뷰를 보이고 싶다”. 그 욕구를 tag로 처리하면 versioning이 불필요해진다.

”Spec이 @deprecated제거 시점을 안 적은 이유”

@deprecated directive는 reason 인자만 받고 removeOn 같은 인자가 없다. spec 위원회의 명시적 결정이었다 — 제거 시점은 운영 정책이지 언어 사양이 아니다. reason 문자열날짜를 적는 컨벤션de facto 표준이 됐고, 일부 (특히 Apollo/Hive)는 별도 directive로 보충한다.

→ 교훈: spec이 덜 정의하는 자리에서 운영 컨벤션이 자란다. spec의 공백생태계의 자유가 된다.


요약 + 다이어그램

GraphQL은 버전이 없다. /v2/graphql은 안티패턴이다. Additive는 항상 safe, breaking@deprecated사용량 분석 후 제거. @deprecated코드 안의 changelog가 아니라 클라이언트가 IDE/codegen에서 보는 changelog. 진화의 책임이 클라이언트에서 빌더로 옮겨진다 — 그게 versionless의 진짜 의미.

이 챕터의 마지막 문서. 다음 챕터(08-theory-and-alternatives)는 이 모든 결정 뒤의 이론적 배경과 대안 접근을 다룬다.