플러그인이 WordPress 데이터 모델을 GraphQL 스키마에 매핑하는 방법
이것은 Gato GraphQL이 WordPress 데이터 모델을 대응하는 GraphQL 스키마에 매핑한 방법입니다.
WordPress 데이터 모델
WordPress에는 다음과 같은 엔티티가 있습니다:
- posts
- pages
- custom posts
- 미디어 요소
- 사용자
- 사용자 역할
- 태그
- 카테고리
- 댓글
- 블록
- 메타 속성
- 기타 (옵션, 플러그인, 테마 등)
이러한 엔티티는 계층 구조를 가질 수 있습니다. 예를 들어, post, page, 미디어 요소는 모두 커스텀 포스트 타입이며, 태그와 카테고리는 모두 택소노미입니다.
다음은 WordPress 데이터베이스 다이어그램으로, 모든 엔티티의 데이터가 어떻게 저장되는지 보여줍니다:

매핑은 DB 다이어그램의 완전한 복제입니까?
WordPress 데이터베이스를 GraphQL 스키마에 매핑할 때, 위의 동일한 다이어그램이 1대1로 유지되는 것인가요?
아니요, 그렇지 않습니다. 데이터베이스 다이어그램은 실제 구현이지만, GraphQL은 클라이언트에서 데이터에 접근하기 위한 인터페이스입니다. 이 두 가지는 관련이 있지만 다를 수 있습니다. GraphQL은 데이터베이스를 의식하지 않습니다: SQL 명령으로 생각하지 않으며, wp_posts나 wp_users라는 데이터베이스 테이블의 존재도 알지 못합니다.
따라서 WordPress의 GraphQL 스키마를 만들 때 데이터베이스 다이어그램을 너무 신경 쓸 필요가 없습니다. 더 나아가, WordPress 데이터 모델의 기술적 부채 일부를 수정하는 GraphQL 스키마를 만들 수도 있습니다.
WordPress 데이터 모델을 GraphQL 스키마로 매핑하기
매핑을 시작해 보겠습니다. 먼저 가능한 한 원래의 엔티티를 타입으로 매핑합니다. WordPress 데이터 모델의 엔티티 목록에서 GraphQL 스키마를 위한 다음 타입을 생성합니다:
PostPageMediaUserUserRolePostTagPostCategoryComment
그다음, 모든 타입에 예상되는 모든 필드를 추가합니다. 스키마를 표현하기 위해 SDL(Schema Definition Language)을 사용할 수 있습니다. (이것은 문서화 목적으로만 사용됩니다. 플러그인 자체는 스키마를 코드화하는 데 SDL을 사용하지 않으며, 모두 PHP 코드입니다.)
Post의 필드(많은 것 중 일부)는 다음과 같습니다:
type Post {
id: ID!
title: String
content: String
excerpt: String
date: Date!
}User의 필드(많은 것 중 일부)는 다음과 같습니다:
type User {
id: ID!
name: String
email: String!
}또한 대응하는 연결(connection)도 생성합니다. 연결은 스칼라(숫자나 문자열 등) 대신 다른 엔티티를 반환하는 필드입니다. 예를 들어, 포스트가 저자를 가지고 사용자가 포스트를 소유하는 것을 표현합니다:
type Post {
author: User!
}
type User {
posts: [Post]
}필드와 연결은 인수를 받을 수도 있습니다. 예를 들어, Post.dateStr을 포맷 가능하게 하고, User.posts에서 항목을 필터링하고, 개수를 제한하고, 정렬할 수 있도록 합니다:
type Post {
dateStr(format: String): Date!
}
type User {
posts(
filter: RootPostsFilterInput
pagination: PostPaginationInput
sort: CustomPostSortInput
): [Post!]!
}
input RootPostsFilterInput {
authorIDs: [ID!]
authorSlug: String
categoryIDs: [ID!]
dateQuery: [DateQueryInput!]
excludeAuthorIDs: [ID!]
excludeIDs: [ID!]
hasPassword: Boolean = false
ids: [ID!]
isSticky: Boolean
metaQuery: [CustomPostMetaQueryInput!]
password: String
search: String
status: [FilterCustomPostStatusEnum!]
tagIDs: [ID!]
tagSlugs: [String!]
}
input PostPaginationInput {
limit: Int
offset: Int
}
input CustomPostSortInput {
by: CustomPostOrderByEnum
order: OrderEnum
}
# ...WordPress 데이터 모델의 모든 엔티티에 대해 이 작업을 계속합니다. 완료되면 WordPress의 GraphQL 스키마에 도달합니다. 이는 Voyager 클라이언트(플러그인 메뉴의 「Interactive Schema」로 이용 가능)를 통해 확인할 수 있습니다:

이 스키마는 WordPress 데이터베이스 다이어그램과 유사한 점도 있지만, 몇 가지 차이점도 있습니다. 이를 분석해 보겠습니다.
엔티티가 없는 작업은 Root 필드로 매핑됩니다
WordPress 데이터베이스 다이어그램은 데이터가 저장되는 방식을 나타내므로 「시작점」이 없습니다. 그러나 GraphQL은 데이터를 가져오기 위한 인터페이스이므로, 쿼리를 실행할 초기 단계가 필요합니다.
이 초기 단계가 Root 타입, 더 정확하게는 QueryRoot와 MutationRoot 타입(각각 쿼리와 뮤테이션을 처리하기 위해)입니다.
이 두 타입에서 get_posts(), get_users(), wp_signon()을 실행할 때처럼 엔티티에 의존하지 않는 모든 작업을 매핑합니다:
type QueryRoot {
posts: [Post]!
users: [User]!
}
type MutationRoot {
loginUser(
usernameOrEmail: String!,
password: String!
): User
}필드는 표현하는 작업과 동일한 이름이나 시그니처를 가질 필요가 없습니다. 예를 들어, 필드 loginUser는 signOn보다 더 적합하다고 볼 수 있습니다.
스키마 요소 그룹화
스키마를 단순화하고 더 유용하게 만들기 위한 개선을 적용할 수 있습니다. 예를 들어, 필드는 모든 인수를 input 객체를 통해 받을 수 있으며, 여러 필드에서 재사용할 수 있어 스키마를 더 쉽게 시각화할 수 있습니다:
type MutationRoot {
loginUser(input: LoginUserByInput!): User
}
input LoginUserByInput {
usernameOrEmail: String!,
password: String!
}또한, 뮤테이션의 응답을 「payload」 객체로 만들 수 있습니다. 이는 영향을 받은 객체를 반환하는 것 외에도 작업의 상태와 오류 메시지를 포함할 수 있습니다:
type MutationRoot {
loginUser(input: LoginUserByInput!): RootLoginUserMutationPayload!
}
type RootLoginUserMutationPayload {
errors: [RootLoginUserMutationErrorPayloadUnion!]
status: OperationStatusEnum!
user: User
userID: ID
}
union RootLoginUserMutationErrorPayloadUnion = GenericErrorPayload
| InvalidUserEmailErrorPayload
| InvalidUsernameErrorPayload
| PasswordIsIncorrectErrorPayload
| UserIsLoggedInErrorPayload모든 뮤테이션은 MutationRoot 아래에 위치합니다
wp_update_post()처럼 특정 엔티티에 의존하는 작업이 있습니다. 이는 특정 포스트에 적용됩니다. GraphQL의 사양상, 대응하는 뮤테이션은 GraphQL 스키마의 MutationRoot 타입에 추가해야 합니다.
이 작업은 다음과 같이 매핑됩니다:
type MutationRoot {
updatePost(input: RootUpdatePostFilterInput!): PostUpdateMutationPayload!
}
input RootUpdatePostFilterInput {
categoryIDs: [ID!]
content: String
featuredImageID: ID
id: ID!
status: CustomPostStatusEnum
tags: [String!]
title: String
}이 플러그인은 중첩 뮤테이션도 지원하며, 옵트인 기능으로 제공됩니다(표준 GraphQL 동작이 아니기 때문입니다). 이 경우, 뮤테이션은 MutationRoot뿐만 아니라 모든 타입 아래에 추가할 수도 있습니다. 이 경우 다음과 같이 됩니다:
type Post {
update(input: PostUpdateFilterInput!): PostUpdateMutationPayload!
}
input PostUpdateFilterInput {
categoryIDs: [ID!]
content: String
featuredImageID: ID
status: CustomPostStatusEnum
tags: [String!]
title: String
}RootUpdatePostFilterInput과 PostUpdateFilterInput의 차이점(즉, 루트에서의 뮤테이션과 중첩 뮤테이션)에 주목하세요: 전자에는 어떤 포스트를 수정할지 나타내는 필수 속성 id가 있지만, 후자에는 필요하지 않으므로 포함되지 않습니다.
커스텀 포스트 처리
GraphQL에는 타입 상속이 없습니다. 따라서 CustomPost 타입을 두고 Post와 Page가 이를 확장하는 것은 불가능합니다.
GraphQL은 이러한 부재를 보완하기 위해 두 가지 리소스를 제공합니다: 인터페이스와 유니온 타입입니다.
첫 번째로, 스키마에 CustomPost 인터페이스를 만들어 커스텀 포스트에 기대되는 모든 필드를 선언하고, 인터페이스를 구현하는 타입으로 Post, Page, GenericCustomPost(설치된 테마와 플러그인에 의해 정의된 모든 커스텀 포스트 타입을 나타냄)를 정의합니다:
interface CustomPost {
title: String
content: String
excerpt: String
date(format: String): Date!
}
type Post implements CustomPost {
title: String
content: String
excerpt: String
date(format: String): Date!
}
type Page implements CustomPost {
title: String
content: String
excerpt: String
date(format: String): Date!
}
type GenericCustomPost implements CustomPost {
title: String
content: String
excerpt: String
date(format: String): Date!
}두 번째로, 모든 커스텀 포스트 타입을 반환하는 CustomPostUnion 타입을 스키마에 만듭니다:
union CustomPostUnion = Post | Page | GenericCustomPost그리고 적절한 경우 필드가 이 타입을 반환하도록 합니다:
type QueryRoot {
customPost(id: ID): CustomPostUnion
customPosts: [CustomPostUnion]!
}
type User {
customPosts: [CustomPostUnion]
}
type Comment {
customPost: CustomPostUnion!
}쿼리를 실행할 때, Post와 같은 실제 타입 또는 CustomPost 인터페이스를 기반으로 필드를 선택할 수 있습니다:
{
customPosts {
__typename
...on CustomPost {
id
title
slug
status
}
...on Post {
isSticky
postFormat
}
}
}보시다시피, GraphQL 스키마에서는 포스트를 다루고 있는지 커스텀 포스트를 다루고 있는지를 명시적으로 나타내야 합니다. 이 두 가지는 동일하지 않기 때문입니다! 이들을 혼용하는 것은 WordPress의 기술적 부채이며, 플러그인은 가능한 한 이를 수정하려고 합니다.
이러한 이유로, 커스텀 포스트는 항상 Post가 아닌 CustomPost로 불리며, 커스텀 포스트를 다루는 필드는 항상 posts가 아닌 customPosts로 불리며, 커스텀 포스트의 ID를 받는 필드 인수는 (매핑된 WordPress 함수에서 그렇게 불리더라도) postID가 아닌 customPostID로 불립니다.
이를 통해 기대 사항이 항상 명확해집니다:
- 필드
User.customPosts는 post와 page를 포함한 모든 커스텀 포스트의 목록을 반환할 수 있으며,User.posts는 post만 반환합니다 - 필드
Root.setFeaturedImageOnCustomPost는 모든 커스텀 포스트에 대표 이미지를 추가할 수 있습니다. 그래서setFeaturedImageOnPost라고 불리지 않습니다
태그(및 카테고리)를 단일 타입으로 그룹화하지 않는 이유
왜 PostTag 타입(그리고 PostCategory도 마찬가지)은 단순히 Tag가 아닌 그런 이름을 가지고 있을까요?
그 이유는, 이 쿼리를 실행할 때(product가 CPT인 경우), post와 product의 tags 필드 결과는 항상 다르고 겹치지 않기 때문입니다:
query {
posts {
tags {
id
name
}
}
products {
tags {
id
name
}
}
}post에 추가된 태그는 product의 태그를 가져올 때 표시되지 않으며, 그 반대도 마찬가지입니다(단, product가 post_tag 택소노미도 사용하는 경우는 예외이지만, 그 경우에도 PostTag 타입으로 표현할 수 있습니다). 이는 WordPress에서는 큰 문제가 되지 않습니다. 왜냐하면 이러한 항목들은 동일한 데이터베이스 테이블의 다른 행으로 간주될 수 있기 때문입니다. 그러나 강한 타입을 가진 GraphQL에서는 중요합니다.
따라서 이러한 엔티티들을 각자의 타입 아래에 별도로 유지하는 것이 좋은 설계 결정입니다. post의 태그는 PostTag 타입으로 반환하고, 커스텀 플러그인이 자체 product CPT를 구현하는 경우 해당 태그에는 ProductTag 타입을 사용해야 합니다.
미디어 아이템에 고유한 정체성 부여하기
WordPress의 미디어 엔티티는 구현상의 편의를 위해 커스텀 포스트 타입이 되었습니다. 그러나 GraphQL 스키마에서는 이 기술적 부채를 피하고, 미디어 요소를 커스텀 포스트가 아닌 별개의 엔티티로 모델링할 수 있습니다.
이는 GraphQL 스키마에 다음과 같은 결정을 가져옵니다:
Media타입은CustomPost인터페이스를 구현하지 않으며,CustomPostUnion타입의 일부도 되지 않습니다Media타입은excerpt,date,status와 같은 커스텀 포스트 타입에 기대되는 많은 필드를 갖지 않습니다. 대신, 미디어 요소에 기대되는 필드만 가집니다:
type Media {
id: ID!
src: String!
width: Int
height: Int
}enum 식별 및 매핑
일부 상황에서 WordPress는 특정 집합에서 고정된 값을 사용합니다. 예를 들어, 포스트의 상태는 "publish", "draft", "pending", "trash" 중 하나만 가능합니다.
GraphQL에서는 이를 (문자열 대신) enum으로 처리하고 대응하는 열거 타입을 만들 수 있습니다. GraphQL 표준에 따라, enum은 대문자로 작성되어야 합니다:
enum CUSTOM_POST_STATUS {
PUBLISH
DRAFT
PENDING
TRASH
}그러나 그렇게 하면 get_posts( [ "post_status" => "PUBLISH" ] )를 실행해도 작동하지 않으므로, 쿼리를 WordPress와의 상호 작용에 직접 사용할 수 없게 됩니다.
따라서 타협안으로, 이러한 enum 값을 소문자로 유지합니다:
enum CUSTOM_POST_STATUS {
publish
draft
pending
trash
}추가 타입 매핑
블록은 wp_posts에 저장되어 있으므로 WordPress 데이터베이스 다이어그램에 직접 표시되지 않습니다(wp_blocks라는 테이블은 존재하지 않습니다). 그러나 그럼에도 불구하고 별개의 엔티티입니다.
따라서 블록을 매핑하기 위한 Block 타입을 도입할 수 있습니다:
type Post {
blocks: [Block]
}
type Block {
type: String!
attributes: JSONObject
}