필드 인수와 디렉티브 비교
GraphQL에서 필드의 출력을 변경하는 동일한 기능은 두 가지 서로 다른 방법으로 구현할 수 있는 경우가 많습니다.
- 필드 인수:
field(arg: value) - 쿼리 타입 디렉티브:
field @directive
(쿼리 타입 디렉티브란 클라이언트 사이드의 쿼리에 적용되는 것으로, 서버에서 스키마를 구축할 때 SDL(Schema Definition Language)을 통해 적용되는 스키마 타입 디렉티브와는 대조적입니다. Gato GraphQL은 SDL이 아닌 PHP 코드로 스키마를 생성하므로, 모든 디렉티브는 쿼리 타입이며 단순히 「디렉티브」라고 부릅니다.)
예를 들어, title 필드의 응답을 대문자로 변환하려면, UPPERCASE라는 enum 값을 갖는 필드 인수 format을 전달하는 방법이 있습니다.
{
posts {
title(format: UPPERCASE)
}
}또는 필드에 @strUpperCase 디렉티브를 적용하는 방법도 있습니다.
{
posts {
title @strUpperCase
}
}두 경우 모두 GraphQL 서버의 응답은 동일합니다.
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}필드 인수와 쿼리 사이드 디렉티브는 각각 언제 사용해야 할까요? 두 방법 사이에 차이가 있는지, 또는 어느 한쪽이 더 나은 상황이 있는지 살펴봅니다.
필드 인수와 디렉티브가 잘하는 것
GraphQL에서 필드를 리졸브하려면 두 가지 서로 다른 작업이 필요합니다.
- 쿼리된 엔티티에서 요청된 데이터를 가져오기
- 가져온 데이터에 기능(포맷 등)을 적용하기
이 두 작업을 각각 「데이터 리졸루션」과 「기능 적용」, 또는 간략히 「데이터」와 「기능」으로 표현할 수 있습니다.
필드 인수와 디렉티브의 주요 차이점은, 필드 인수는 「데이터」와 「기능」 모두에 사용할 수 있지만, 디렉티브는 「기능」에만 사용할 수 있다는 점입니다.
이것이 무엇을 의미하는지 좀 더 자세히 살펴보겠습니다.
필드 인수를 통한 데이터 리졸브
필드 인수는 필드를 리졸브할 때 처리되므로, 객체의 어떤 프로퍼티에 접근할지 결정하는 것과 같이 실제 데이터를 가져오는 데 사용할 수 있습니다.
예를 들어, 다음 리졸버 코드는 인수 size를 사용하여 Media 객체 타입에서 이미지 소스를 가져오는 방법을 보여줍니다.
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}필드 인수는 DB 테이블에서 어떤 행이나 열을 쿼리해야 하는지 결정하는 데도 사용할 수 있습니다.
이 쿼리에서는 필드 인수 id를 사용하여 Post 타입의 특정 엔티티를 쿼리하며, 리졸버는 이를 WordPress의 wp_posts DB 테이블의 특정 행으로 변환합니다.
{
post(by: { id: 1 }) {
title
}
}같은 테이블에는 게시물 날짜가 post_modified와 post_modified_gmt라는 두 개의 서로 다른 컬럼에 저장되어 있습니다(하위 호환성을 위해서입니다). 이 쿼리에서는 필드 인수 gmt에 true 또는 false를 전달하여 어느 컬럼의 값을 가져올지 결정합니다.
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}이러한 예시들은 필드 인수가 필드를 리졸브할 때 데이터 소스를 변경할 수 있음을 보여줍니다.
디렉티브는 데이터 소스를 변경하는 데 사용할 수 없습니다. 왜냐하면 디렉티브의 로직은 필드 리졸버 이후에 호출되는 디렉티브 리졸버를 통해 제공되기 때문입니다. 따라서 디렉티브가 적용될 시점에는 필드의 값이 이미 가져와져 있어야 합니다.
예를 들어, 다음 쿼리는 절대 작동하지 않습니다.
{
post @selectEntity(id: 1) {
title
}
}이 예시에서 필드 post는 엔티티의 id를 제공받아야 하지만, 필드 인수로 제공되지 않았으므로 서버는 오류를 반환합니다.
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}결론적으로, 필드를 리졸브하는 데이터를 가져오는 데 도움을 줄 수 있는 것은 필드 인수뿐입니다.
필드 인수 또는 디렉티브를 통한 기능 적용
필드의 데이터를 가져온 후에는 그 값을 조작하고 싶을 때가 있습니다. 예를 들어, 다음과 같은 작업을 할 수 있습니다.
- 문자열을 포맷하여 대문자 또는 소문자로 변환하기
- 문자열로 표현된 날짜를 기본
YYYY-mm-dd형식에서dd/mm/YYYY형식으로 변환하기 - 문자열을 마스킹하여 이메일과 전화번호를
***로 대체하기 - 값이
null이거나 비어 있을 경우 기본값 제공하기 - 부동소수점 숫자를 소수점 이하 2자리로 반올림하기
이러한 작업들은 모두 이미 가져온 데이터에 대한 조작입니다. 따라서 필드 리졸버 내(데이터를 가져온 직후, 반환하기 전)에도, 필드의 값을 입력으로 받는 디렉티브 리졸버에서도 코딩할 수 있습니다. 따라서 이러한 작업들은 모두 필드 인수 또는 디렉티브로 구현할 수 있습니다.
예를 들어, Post.excerpt의 필드 리졸버는 필드 인수 default를 통해 기본값을 제공할 수 있으며, 쿼리에서 default 인수의 값을 커스터마이즈할 수 있습니다.
{
posts {
excerpt(default: "(No excerpt)")
}
}@default 디렉티브를 만들고, 다음과 같은 디렉티브 리졸버를 사용할 수도 있습니다.
/**
* Replace all the empty results with the default value
*/
function resolveDirective(
array $directiveArgs,
array $objectIDFields,
array $objectsByID,
array &$responseByObjectIDAndField
): void {
foreach ($objectIDFields as $id => $fields) {
$object = $objectsByID[$id];
$defaultValue = $directiveArgs['value'];
foreach ($fields as $field) {
if (empty($responseByObjectIDAndField[$id][$field])) {
$responseByObjectIDAndField[$id][$field] = $defaultValue;
}
}
}
}이 두 가지 전략은 동등하게 적합할까요? 다양한 관점에서 이 질문을 탐구해 보겠습니다.
필드 인수는 GraphQL 사양에서 더 충분히 다루어지고 있습니다
디렉티브가 작동할 수 있는 범위는 GraphQL 사양에서 명확히 정의되어 있지 않으며, 사양에는 다음과 같이 기술되어 있습니다.
Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.
이 정의는 조건에 따라 필드를 포함하거나 건너뛰는 @include와 @skip, 그리고 서버로부터 데이터를 가져올 때 다른 런타임 실행을 제공하는 @stream과 @defer 같은 디렉티브의 사용을 인정합니다.
그러나 이 정의는 출력값 "Hello world!"를 "HELLO WORLD!"로 변환하는 @strUpperCase처럼 필드의 값을 변경하는 디렉티브에 대해서는 명확하지 않습니다.
이러한 모호함으로 인해, 서로 다른 GraphQL 서버, 클라이언트, 도구들이 디렉티브를 서로 다른 정도로 고려하여 그 사이에서 충돌이 발생할 수 있습니다.
그 예로 Relay가 있으며, 필드 값을 캐싱할 때 디렉티브를 고려하지 않습니다. 처음에 다음 쿼리를 실행하면:
{
post(by: { id: 1 }) {
title
}
}...Relay는 ID 1의 게시물에 대해 "Hello world!"라는 값을 쿼리하고 캐시합니다. 그런 다음 이 쿼리를 실행하면:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...응답은 "HELLO WORLD!"여야 하지만, Relay는 필드에 적용된 디렉티브를 무시하고 ID 1의 게시물에 대해 캐시에 저장된 값인 "Hello world!"를 반환합니다.
디렉티브가 필드의 출력값을 변경하는 것이 허용되는지 여부는 회색 지대에 있습니다. GraphQL 사양에서 명시적으로 허용되지도 금지되지도 않았지만, 양쪽 상황 모두에 대한 지표가 존재합니다.
한편으로는, GraphQL 사양이 디렉티브에 GraphQL을 개선하고 커스터마이즈할 자유로운 수단을 부여하는 것처럼 보입니다.
As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.
다른 한편으로는, 사양이 FieldsInSetCanMerge 유효성 검사나 CollectFields 알고리즘에서 디렉티브를 고려하지 않습니다. 다음 GraphQL 쿼리는 유효하지만, 사용자가 어떤 응답을 받을지는 불확실합니다.
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}GraphQL 서버의 동작에 따라 필드 name의 응답은 "Leo", "LEO", 또는 "leo"가 될 수 있습니다... 사전에는 알 수 없으며, 이것이 문제입니다.
필드 인수에서는 같은 문제가 발생하지 않습니다. 다음 쿼리가 실행되면:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...사양은 GraphQL 서버가 오류를 반환하도록 지시하므로 name의 값은 null이 됩니다. 그러면 쿼리를 실행하기 위해 별칭을 도입해야 합니다.
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}디렉티브는 모듈성과 코드 재사용성에 더 뛰어납니다
디렉티브가 제공하는 많은 작업들은 적용되는 엔티티나 필드와 무관합니다. 예를 들어, @strUpperCase는 게시물의 제목, 사용자의 이름, 위치의 주소 등 어떤 문자열에도 작동합니다.
그 결과, 이 디렉티브의 코드는 디렉티브 리졸버라는 단 한 곳에만 구현됩니다. 관점 지향 프로그래밍(횡단 관심사의 분리를 통해 모듈성을 높임)과 마찬가지로, 디렉티브는 필드의 로직에 영향을 주지 않고 필드에 적용됩니다.
반대로, 필드 인수를 통해 같은 기능을 구현하면 필드 리졸버 전체(그리고 서로 다른 필드 리졸버 전체)에서 동일한 코드를 실행해야 합니다.
function formatString(string $string, string $format): string
{
if ($format === "UPPERCASE") {
return strtoupper($string);
}
if ($format === "LOWERCASE") {
return strtolower($string);;
}
return $string;
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$format = $fieldDataAccessor->getValue('format');
if ($fieldDataAccessor->getFieldName() === 'title') {
return formatString($post->post_title, $format);
}
if ($fieldDataAccessor->getFieldName() === 'excerpt') {
return formatString($post->post_excerpt, $format);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return formatString($post->post_content, $format);
}
// ...
}리졸버의 코드 양을 줄이기 위해서는 필드 인수보다 디렉티브가 더 적합합니다.
디렉티브는 스키마 설계에 더 뛰어납니다
필드 인수를 추가하면 스키마에 불필요한 정보가 추가되어 스키마가 비대해지고 일관성이 떨어질 수 있습니다.
예를 들어, 필드 인수 format은 모든 String 필드에 추가해야 하며, 주의하지 않으면 서로 다른 이름, 서로 다른 값, 서로 다른 기본값을 사용하거나 인수를 여러 입력으로 분할하는 등 필드 간에 일관성이 없을 수 있습니다.
type Post {
# Input value is "uppercase" or "strLowerCase"
title(format: String): String
content(format: String): String
excerpt(format: String): String
}
type Category {
# Input name is "case" instead of "format"
# Input value is an enum StringCase with values UPPERCASE and LOWERCASE
name(case: StringCase): String
}
type Tag {
# Using a default value
name(format: String = "strLowerCase"): String
}
type User {
# Using multiple Boolean inputs
description(useUppercase: Boolean, useLowercase: Boolean): String
}디렉티브를 사용하면 스키마를 최대한 간결하게 유지할 수 있습니다.
directive @strUpperCase on FIELD
directive @strLowerCase on FIELD
type Post {
title: String
content: String
excerpt: String
}
type Category {
name: String
}
type Tag {
name: String
}
type User {
description: String
}디렉티브는 필드 인수보다 효율적일 수 있습니다
실행 시간에 필드 인수는 필드를 리졸브할 때 접근됩니다. 이는 필드별, 객체별로 이루어집니다. 예를 들어, 게시물 목록에서 title과 content 필드를 리졸브할 때 리졸버는 게시물과 필드의 조합마다 한 번씩 호출됩니다.
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Google Translate API를 사용하여 이러한 문자열을 번역하고 싶다고 가정해 봅시다. 이를 위해 인수 translateTo를 추가합니다.
function executeGoogleTranslate(string $string, string $lang): string
{
// Execute against https://translation.googleapis.com
// ...
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$lang = $fieldDataAccessor->getValue('lang');
if ($fieldDataAccessor->getFieldName() === 'title') {
return executeGoogleTranslate($post->post_title, $lang);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return executeGoogleTranslate($post->post_content, $lang);
}
// ...
}로직이 필드와 객체의 조합마다 자연스럽게 실행되므로, 외부 API에 대한 연결이 많이 발생하여 쿼리를 리졸브하는 응답 속도가 느려질 수 있습니다.
또한 각 호출을 독립적으로 실행하면 해당 데이터를 서로 연관시킬 수 없으므로, 모든 데이터를 단일 API 호출로 함께 제출했을 경우보다 번역 품질이 낮아집니다.
예를 들어, 게시물 제목 "Power"는 이 단어가 「전력」을 의미함을 명확히 하는 게시물 내용과 함께 제출되면 더 적절하게 번역될 수 있습니다.
Gato GraphQL은 디렉티브를 단 한 번만 호출하며, 적용할 모든 필드와 객체를 입력으로 전달합니다. 모든 데이터를 한꺼번에 받음으로써 @strTranslate 디렉티브는 모든 객체의 모든 title과 content 필드를 전달하여 Google Translate에 단일 호출을 실행할 수 있습니다. 다음 쿼리처럼요.
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}디렉티브는 외부 API와의 상호작용 등에서 필드 값을 변경하는 더 효율적인 방법을 제공할 수 있습니다.