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 | 위치 | 역할 |
|---|---|---|
@deprecated | FIELD_DEFINITION, ENUM_VALUE, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITION | introspection에 deprecated 마크 |
@specifiedBy | SCALAR | custom scalar의 spec URL 지시 |
@skip | FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT | 쿼리 시 필드 제외 |
@include | FIELD, 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: true와deprecationReason이 노출. - 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_DEFINITIONDirective 정의 문법
directive @<name>(<args>) [repeatable] on <Location> | <Location> | ...가능한 location (총 19개, GraphQL spec October 2021 §3.13):
Type system 위치:
SCHEMASCALAROBJECTFIELD_DEFINITIONARGUMENT_DEFINITIONINTERFACEUNIONENUMENUM_VALUEINPUT_OBJECTINPUT_FIELD_DEFINITION
Executable 위치:
QUERYMUTATIONSUBSCRIPTIONFIELDFRAGMENT_DEFINITIONFRAGMENT_SPREADINLINE_FRAGMENTVARIABLE_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-tools의 mapSchema가 표준. 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 안에서 info의 parentType/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")
}@deprecated는 introspection 메타일 뿐. 실제로 호출되면 그대로 응답한다. 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을 표준으로 적는 방법이 없다 —
DeprecationHTTP 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정의 문법은 정의하되 어떻게 실행되어야 하는지는 서버 구현에 맡긴다. 그래서 같은@authdirective가 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가
@oneOfdirective를 채택 — 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를 다룬다.