필드 버전 관리를 통한 스키마 진화
애플리케이션의 요구 사항이 발전함에 따라, 데이터를 제공하는 GraphQL API도 스키마에 변경을 가하면서 함께 진화해야 합니다. 새로운 타입이나 필드를 추가하는 것과 같이 변경이 비파괴적인 경우에는 부작용을 걱정하지 않고 직접 적용할 수 있습니다. 하지만 변경이 파괴적인 경우에는 애플리케이션에 버그나 예상치 못한 동작을 도입하지 않도록 해야 합니다.
파괴적인 변경이란 타입, 필드, 또는 디렉티브를 제거하거나, 이미 존재하는 필드(또는 디렉티브)의 시그니처를 수정하는 것을 말합니다. 예를 들면 다음과 같습니다:
- 필드 이름 변경
- 기존 필드 인수의 타입 변경, 또는 필수화
- 필드에 새로운 필수 인수 추가
- 필드의 응답 타입에 non-nullable 추가
파괴적인 변경에 대처하기 위한 주요 전략은 두 가지입니다: REST와 GraphQL이 각각 구현하는 버전 관리(versioning)와 진화(evolution)입니다.
REST API는 엔드포인트 URL(https://api.mycompany.com/v1 또는 https://api-v1.mycompany.com 등) 또는 일부 헤더(Accept-version: v1 등)를 통해 사용할 API 버전을 나타냅니다. 버전 관리를 통해 파괴적인 변경은 API의 새 버전에 추가되며, 클라이언트는 새 버전의 API를 명시적으로 지정해야 하므로 변경 사항을 인식할 수 있습니다.
GraphQL은 버전 관리 사용을 배제하지 않지만, 진화 사용을 권장합니다. GraphQL best practices 페이지에 명시된 것처럼:
While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.
진화는 버전 관리와는 다르게 동작합니다. 버전 관리처럼 몇 달에 한 번씩 이루어지는 것이 아니라, 필요하다면 매일도 이루어지는 지속적인 프로세스이므로 빠른 반복(iteration)에 더 적합합니다. 이 접근 방식은 GraphQL 서비스 개발을 안내하는 모범 사례 모음인 Principled GraphQL이 그다섯번째 원칙으로 제시하고 있습니다:
5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time
스키마의 진화
진화를 통해, 파괴적인 변경을 가진 필드는 다음 프로세스를 거쳐야 합니다:
- 다른 이름을 사용하여 필드를 재구현합니다.
- 필드를 deprecated(비권장)로 표시하고, 클라이언트에게 대신 새 필드를 사용하도록 요청합니다.
- 필드가 더 이상 아무도 사용하지 않을 때, 스키마에서 제거합니다.
예시를 살펴보겠습니다. GraphQL의 SDL(Schema Definition Language)을 사용하여 이름과 성을 가진 사람으로 계정을 모델링하는 Account 타입이 있다고 가정합니다:
type Account {
id: Int
name: String!
surname: String!
}이 스키마에서 name과 surname 필드는 모두 필수입니다(타입 String 뒤에 추가된 ! 기호가 이를 나타냅니다). 모든 사람이 이름과 성을 모두 갖는다고 가정하기 때문입니다.
이후 조직도 계정을 개설할 수 있도록 허용한다고 가정합니다. 그런데 조직은 성(surname)이 없으므로, surname 필드의 시그니처를 변경하여 필수가 아니도록 해야 합니다:
type Account {
id: Int
name: String!
surname: String # 이 부분이 변경되었습니다
}이것은 파괴적인 변경입니다. 애플리케이션은 surname 필드가 null을 반환할 것을 예상하지 않으므로, 다음 JavaScript 코드를 실행할 때처럼 이 조건을 확인하지 않을 수 있기 때문입니다:
// account.surname 이 null일 때 이 코드는 실패합니다
const upperCaseSurname = account.surname.toUpperCase();파괴적인 변경에서 발생할 수 있는 잠재적 버그는 스키마를 진화시킴으로써 피할 수 있습니다:
surname필드의 시그니처를 수정하지 않고, 대신 deprecated로 표시하고 이를 대체하는 필드의 이름을 안내하는 유용한 메시지를 추가합니다- 스키마에 새 필드 이름
personSurname(또는accountSurname)을 도입합니다
이제 Account 타입은 다음과 같이 됩니다:
type Account {
id: Int
name: String!
surname: String! @deprecated(reason: "Use `personSurname`")
personSurname: String
}마지막으로, 클라이언트에서 오는 쿼리 로그를 수집하여 새 필드로 전환이 이루어졌는지 분석할 수 있습니다. surname 필드가 더 이상 아무도 사용하지 않는다는 것을 확인하면, 스키마에서 제거할 수 있습니다:
type Account {
id: Int
name: String!
personSurname: String
}진화에 따른 문제점
위에서 설명한 예시는 매우 단순하지만, 스키마를 진화시킬 때의 잠재적인 몇 가지 문제점을 이미 보여주고 있습니다:
| 문제 | 설명 |
|---|---|
| 필드 이름이 덜 세련되어짐 | 처음 필드에 이름을 붙일 때는 surname과 같이 최적의 이름을 찾을 수 있습니다. 하지만 교체해야 할 때는 이미 최적의 이름이 사용 중이므로 차선책 이름을 만들어야 합니다. 위의 예시에서 가능한 모든 대안에는 문제가 있습니다:- personName은 계정이 사람을 위한 것임을 명시하므로, 나중에 성(surname)을 가진 비인간(예를 들어... 화성인?)의 계정을 개설해야 한다면 일관된 이름을 유지하기 위해 스키마를 다시 진화시켜야 합니다- accountName의 "account" 부분은 타입이 이미 Account이므로 완전히 중복됩니다- 그렇다면 다른 어떤 이름을 사용해야 할까요? surname1? surnameNew? 아니면 더 나쁜 surnameV2?결과적으로 업데이트된 스키마는 이해하기 어렵고 더 장황해집니다. |
| 스키마에 deprecated 필드가 쌓일 수 있음 | 필드를 deprecated로 표시하는 것은 일시적인 상황으로서 가장 적합합니다. 결국에는 쌓이기 전에 스키마에서 해당 필드를 제거하여 정리하고 싶을 것입니다. 하지만 쿼리를 수정하지 않고 deprecated 필드에서 계속 정보를 가져오는 클라이언트가 있을 수 있습니다. 이 경우 스키마는 동일한 기능에 대한 여러 다른 필드가 쌓여가는 「필드의 묘지」같은 것이 될 것입니다. |
이러한 문제들을 해결하는 방법을 살펴보겠습니다.
필드 버전 관리
필드에 version이라는 인수를 만들어, 사용할 필드의 버전을 지정할 수 있습니다.
이 시나리오에서는 deprecated 필드의 구현을 계속 유지해야 하므로 그 측면에서는 개선이 없습니다. 그러나 그 계약은 숨겨집니다. 새 필드는 이제 원래 이름을 유지할 수 있습니다(surname에서 personSurname으로 이름을 변경할 필요가 없습니다). 이로써 스키마가 지나치게 장황해지는 것을 방지합니다.
이 버전 관리 개념은 REST의 것과 다르다는 점에 유의하세요:
- REST는 버전이 엔드포인트의 일부이므로 쿼리된 API 전체가 동일한 버전을 갖는 전부 아니면 전무(all-or-nothing) 상황을 설정합니다
- 이 접근 방식에서는 각 필드가 독립적으로 버전 관리됩니다
따라서 다음과 같이 서로 다른 필드에 대해 서로 다른 버전에 액세스할 수 있습니다:
query GetPosts {
posts(version: "1.0.0") {
id
title(version: "2.1.1")
url
author {
id
name(version: "1.5.3")
}
}
}또한, semantic versioning을 기반으로 패키지 의존성을 선언하기 위해 Composer가 사용하는 규칙에 따라 버전 제약 조건을 사용하여 버전을 선택할 수 있습니다. 그런 다음 필드 인수 version을 versionConstraint로 이름을 변경하고 쿼리를 업데이트합니다:
query GetPosts {
posts(versionConstraint: "^1.0") {
id
title(versionConstraint: ">=2.1")
url
author {
id
name(versionConstraint: "~1.5.3")
}
}
}이 전략을 deprecated 필드 surname에 적용하면, deprecated 구현을 버전 "1.0.0"으로, 새 구현을 버전 "2.0.0"으로 태그하고 같은 쿼리 내에서 둘 다 액세스할 수 있습니다:
query GetSurname {
account(id: 1) {
oldVersion: surname(versionConstraint: "^1.0")
newVersion: surname(versionConstraint: "^2.0")
}
}이 기능은 Gato GraphQL에서 사용 가능합니다:

디렉티브 버전 관리
디렉티브도 인수를 받으므로, 완전히 동일한 방법론을 적용하여 디렉티브도 버전 관리할 수 있습니다!
예를 들어, 다음 쿼리를 실행하면:
query {
post(by: { id: 1 }) {
oldVersion: title @strTitleCase(versionConstraint: "^0.1")
newVersion: title @strTitleCase(versionConstraint: "^0.2")
}
}디렉티브의 각 버전에 대해 서로 다른 응답을 생성할 수 있습니다:
