🔷 GraphQL1. 스키마 & SDL05 · Directive — 동작 변경자

05 · Directive — 동작 변경자

이 문서가 답하는 질문: 디렉티브는 무엇이고, 왜 주석이 아니라 동작 변경자인가? 표준 directive와 custom directive는 어떻게 다르며 언제 어떻게 쓰나? 한 줄 답 (Pyramid Top): “디렉티브는 @로 시작하는 SDL/쿼리 위의 부착물이지만, 주석이 아니다 — 표준 directive는 query 실행을 바꾸고, custom directive는 schema나 resolver를 변환한다.”


Why — 왜 디렉티브가 필요한가

스키마와 쿼리에 추가 정보를 적고 싶은 상황은 늘 있다.

  • “이 필드는 deprecated인데 introspection에 표시하고 싶다”
  • “이 필드는 조건부로만 응답에 포함되어야 한다”
  • “이 필드 호출 전에 권한 검증을 통과해야 한다”
  • “이 필드는 5초간 캐시되어야 한다”
  • “이 input은 최대 길이 100이어야 한다”

각각을 새로운 SDL 키워드로 추가하면 spec이 폭발한다. 대신 GraphQL은 확장 가능한 메커니즘directive를 만들었다.

field: String @deprecated(reason: "use newField") @cache(maxAge: 300)

핵심 통찰 — 디렉티브는 주석이 아니다.

JavaScript의 // 주석런타임에 사라진다. GraphQL directive는 런타임이 읽고 동작을 바꾼다. JVM annotation·Python decorator·Rust attribute에 더 가깝다.


How — 두 종류의 디렉티브

Executable directive (query에 붙는다)

쿼리·뮤테이션·서브스크립션 문서 위의 위치에 붙어 실행 동작을 바꾼다.

표준 executable directive — @skip, @include:

query GetUser($withPosts: Boolean!) {
  user(id: "1") {
    name
    posts @include(if: $withPosts) { title }
    bio @skip(if: $withPosts)
  }
}
  • @include(if: true) → 필드 포함, if: false → 필드 제외.
  • @skip은 반대. @include(if: false)@skip(if: true)는 동일.
  • 두 directive를 같이 쓰면 둘 다 false여야 포함 (양쪽 통과).

이 directive는 서버 resolver를 호출하지 않는다. execution 단계 전에 selection set이 변형된다.

Type system directive (schema에 붙는다)

스키마의 type/field/argument 위에 붙어 스키마의 메타 정보 또는 실행 변환을 한다.

표준 type system directive 4종:

type User {
  oldField: String @deprecated(reason: "use newField")
  email: String @specifiedBy(url: "https://www.rfc-editor.org/rfc/rfc5322")
}
Directive위치역할
@deprecatedFIELD_DEFINITION, ENUM_VALUE, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITIONintrospection에 deprecated 마크
@specifiedBySCALARcustom scalar의 spec URL 지시
@skipFIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT쿼리 시 필드 제외
@includeFIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT쿼리 시 필드 포함

중요: 표준 type system directive(@deprecated·@specifiedBy)는 동작을 바꾸지 않는다 — 단지 introspection에 정보를 노출한다. 그래서 “사이드 이펙트 없음”.

반면 custom directive서버가 직접 의미를 해석하므로 실제로 동작을 바꿀 수 있다.


What — 표준 directive 4종 상세

1) @deprecated

type Mutation {
  signUp(
    name: String!
    email: String!
    fullName: String @deprecated(reason: "use name instead")
  ): User!
}
 
enum Role {
  ADMIN
  EDITOR
  READER
  SUPERUSER @deprecated(reason: "merged into ADMIN")
}
  • 사이드 이펙트 없음 — 필드는 여전히 정상 동작.
  • introspection으로 isDeprecated: truedeprecationReason이 노출.
  • GraphiQL·Apollo Studio·코드 생성기가 경고 표시.

2) @specifiedBy

scalar EmailAddress @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc5322")
scalar URL @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3986")
  • 사이드 이펙트 없음.
  • custom scalar가 어느 표준을 따르는지 introspection에 노출.
  • graphql-scalars 라이브러리의 모든 scalar는 이걸 붙인다.

3) @skip, @include

query Profile($adminMode: Boolean!) {
  user(id: "1") {
    name
    salary @include(if: $adminMode)
  }
}

adminMode=false일 때 응답:

{ "data": { "user": { "name": "Lee" } } }   // salary 키 자체가 없음

동작 차이: salary: null이 아니라 키 자체가 빠진다. 클라이언트 응답 모양이 조건부로 변한다.


What — Custom directive 정의

directive @auth(role: Role!) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: Int!) on FIELD_DEFINITION
directive @length(max: Int!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @cache(maxAge: Int!) on FIELD_DEFINITION

Directive 정의 문법

directive @<name>(<args>) [repeatable] on <Location> | <Location> | ...

가능한 location (총 19개, GraphQL spec October 2021 §3.13):

Type system 위치:

  • SCHEMA
  • SCALAR
  • OBJECT
  • FIELD_DEFINITION
  • ARGUMENT_DEFINITION
  • INTERFACE
  • UNION
  • ENUM
  • ENUM_VALUE
  • INPUT_OBJECT
  • INPUT_FIELD_DEFINITION

Executable 위치:

  • QUERY
  • MUTATION
  • SUBSCRIPTION
  • FIELD
  • FRAGMENT_DEFINITION
  • FRAGMENT_SPREAD
  • INLINE_FRAGMENT
  • VARIABLE_DEFINITION

repeatable — 같은 위치에 여러 번

directive @log(level: String!) repeatable on FIELD_DEFINITION
 
type Query {
  user(id: ID!): User
    @log(level: "info")
    @log(level: "debug")        # repeatable이라 OK
}

repeatable 없으면 같은 directive를 같은 위치에 두 번 사용 불가.

Custom directive의 두 가지 구현 방식

방식 1) Schema transformation — 스키마 자체를 변환

graphql-toolsmapSchema가 표준. directive가 붙은 field를 resolver wrapping된 새 field교체한다.

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'
 
function authDirective(schema, directiveName = 'auth') {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDir = getDirective(schema, fieldConfig, directiveName)?.[0]
      if (!authDir) return fieldConfig
      const requiredRole = authDir.role
      const { resolve = defaultFieldResolver } = fieldConfig
      fieldConfig.resolve = async (src, args, ctx, info) => {
        if (!ctx.user || ctx.user.role !== requiredRole) {
          throw new Error('Forbidden')
        }
        return resolve(src, args, ctx, info)
      }
      return fieldConfig
    },
  })
}
 
const schema = authDirective(makeExecutableSchema({ typeDefs, resolvers }))

이 방식은 resolver를 한 번 wrapping하므로 런타임 오버헤드 거의 없음.

방식 2) Per-request execution check — execution 시 매번 확인

resolver 안에서 infoparentType/fieldName을 읽어 현재 field에 붙은 directive를 검사. 매 요청 비용 발생.

대부분의 프로덕션 구현은 방식 1.

실전 예 — @auth directive

directive @auth(role: Role = READER) on FIELD_DEFINITION
 
type Query {
  me: User!                              # 모두 가능
  users: [User!]! @auth(role: ADMIN)     # ADMIN만
}
 
type Mutation {
  deleteUser(id: ID!): User! @auth(role: ADMIN)
}

스키마를 읽기만 해도 권한 정책이 보인다 — 코드 안에 흩어진 if (user.role !== 'ADMIN') throw선언적으로 표현한다.

실전 예 — @cost directive (Shopify, GitHub)

directive @cost(value: Int!) on FIELD_DEFINITION
 
type Query {
  user(id: ID!): User @cost(value: 1)
  searchUsers(q: String!): [User!]! @cost(value: 10)
  exportAllUsers: [User!]! @cost(value: 1000)
}

GitHub·Shopify는 rate limiting을 쿼리 비용 단위로 한다. 디렉티브로 각 field의 비용을 표시하고, 쿼리 전체의 합을 limit과 비교.


What-if — 자주 틀리는 패턴

함정 1) Directive를 메타데이터로만 오해

type Query {
  users: [User!]! @auth(role: ADMIN)
}

@auth적기만 했을 뿐 서버에 authDirective(schema) 변환을 적용하지 않으면 — 그냥 introspection 메타 정보일 뿐 동작이 안 바뀐다.

대응: custom directive는 반드시 schema transformer 또는 execution hook과 으로 등록.

함정 2) @deprecated에 부작용을 기대

type User {
  oldField: String @deprecated(reason: "removed soon")
}

@deprecatedintrospection 메타일 뿐. 실제로 호출되면 그대로 응답한다. log 출력이나 metric은 기대 못 함 — 별도 resolver wrapper 필요.

함정 3) @include/@skip을 fragment에 잘못 적용

fragment UserFields on User {
  name
  email
}
 
query {
  user(id: "1") {
    ...UserFields @include(if: $isFull)   # ✅ FRAGMENT_SPREAD 위치
  }
}

fragment UserFields 정의 자체에는 @include/@skip을 못 붙인다 (FRAGMENT_DEFINITION 위치가 아니라 FRAGMENT_SPREAD만 valid).

함정 4) Custom directive를 client directive로 잘못 정의

directive @auth(role: Role!) on FIELD     # ❌ FIELD는 query 위치

@auth스키마 위(FIELD_DEFINITION)에 붙어야 하지 쿼리 안의 field에 붙는 게 아니다. 잘못 적으면 스키마 등록은 통과하되 클라이언트 쿼리에서만 쓰이게 되어 의도와 정반대.

원칙: 스키마 작성자가 서버 동작을 바꾸기 위해 적는 directive는 FIELD_DEFINITION/OBJECT/ARGUMENT_DEFINITION_DEFINITION 위치.

함정 5) @include + @skip을 동시에 쓰기

field @include(if: $a) @skip(if: $b)

spec상 허용되지만 논리 표현이 헷갈린다. 결과: $a && !$b.

대응: 하나의 boolean variable로 명시적으로 표현.


Insight — 흥미로운 이야기

@deprecated는 GraphQL의 가장 영향력 있는 directive다”

REST에는 deprecation을 표준으로 적는 방법이 없다Deprecation HTTP header가 RFC로 표준화된 게 2022년(RFC 9745). GraphQL은 2015 첫 spec부터 필드/enum value에 언어 차원의 deprecation을 가진다. 그 결과 클라이언트 도구가 자동으로 deprecation 경고를 표시하고, 코드 생성기가 deprecated 사용에 typescript-eslint 경고를 띄울 수 있다. Shopify·GitHub의 API 진화 전략@deprecated로 알리고 12개월 유예가 기본 — 이게 가능한 이유는 직접적 언어 지원 때문.

“Custom directive는 GraphQL spec이 의도적으로 미정의로 남긴 영역이다”

GraphQL spec은 directive @foo 정의 문법은 정의하되 어떻게 실행되어야 하는지서버 구현에 맡긴다. 그래서 같은 @auth directive가 Apollo Server, GraphQL Yoga, Hot Chocolate(.NET), Sangria(Scala)에서 전혀 다른 방식으로 구현된다. 이게 유연성이자 파편화다. graphql-tools가 사실상 표준 schema transformer가 된 이유 — 여러 서버가 같은 API를 채택.

@defer/@stream은 spec 후보지만 아직 정식 아님”

부분 응답 스트리밍을 위한 @defer(필드를 나중에 보냄)·@stream(list를 stream으로)이 spec proposal로 논의 중(2024 기준 RFC stage). Apollo Server·GraphQL Yoga는 이미 구현했지만 spec에는 미포함. 이게 들어가면 initial response가 빠르게 오고 나머지가 점진 추가되는 GraphQL이 가능해진다 — Suspense + 점진적 hydration과 짝.

“@oneOf — 2024년에 들어온 새 표준 directive”

2024년 spec WG가 @oneOf directive를 채택 — input의 정확히 한 필드만 채워야 함을 표현.

input PetFilter @oneOf {
  id: ID
  name: String
  breed: String
}

“id 또는 name 또는 breed 중 하나로 검색”을 언어 차원에서 표현 — 이전엔 resolver 내부 검증이 필요했다. discriminated input의 GraphQL판.


요약 + Mermaid

요약: 디렉티브는 주석이 아니라 동작 변경자. Executable(query 위)은 실행을 변형하고, type system(schema 위)은 introspection 메타 또는 resolver 변환을 한다. 표준 4종(@deprecated·@specifiedBy·@skip·@include)은 부작용이 약하지만, custom directive반드시 schema transformer와 짝으로 등록되어야 동작한다. 다음 문서(06-custom-scalars)는 directive와 자주 같이 쓰이는 custom scalar를 다룬다.