06 · Custom Scalar

이 문서가 답하는 질문: 표준 5종 스칼라로 충분하지 않을 때 어떻게 custom scalar를 만들고, parseValue·parseLiteral·serialize 세 함수는 각각 언제 호출되는가? 한 줄 답 (Pyramid Top): “custom scalar는 wire 표현 ↔ 서버 메모리 표현 변환을 정의하는 3 함수(parseValue·parseLiteral·serialize)이고, 표준에 없는 모든 leaf 타입(DateTime·JSON·Email·URL·UUID…)이 여기에 속한다.”


Why — 왜 custom scalar가 필요한가

표준 scalar 5종(Int·Float·String·Boolean·ID)으로 모든 leaf 값을 표현할 수 있나? 기술적으로는 가능하다. 모든 것을 String으로 적으면 된다.

type Post {
  publishedAt: String!     # ISO-8601? Unix? Locale?
  metadata: String!        # JSON 문자열?
  authorEmail: String!     # email 검증은?
  websiteUrl: String!      # URL 검증은?
}

문제:

  1. 스키마에 포맷이 안 적혀 있다 — 클라이언트가 별도 문서를 봐야 한다.
  2. 서버가 검증을 안 한다"not-an-email"이 통과한다.
  3. 클라이언트 타입 생성기가 도와줄 수 없다 — TS string으로 떨어진다.
  4. wire 직렬화가 매번 ad-hoc — 어디서는 ISO-8601, 어디서는 Unix timestamp.

custom scalar스키마 한 줄로 이 모든 걸 해결한다.

scalar DateTime
scalar JSON
scalar EmailAddress
scalar URL
 
type Post {
  publishedAt: DateTime!
  metadata: JSON!
  authorEmail: EmailAddress!
  websiteUrl: URL!
}

이제 스키마가 포맷을 선언하고, 서버가 자동 검증하며, 클라이언트가 typed accessor를 받는다.


How — 3개의 함수

1) parseValue — variable로 들어온 값을 서버 객체로

클라이언트가 variables로 보낸 JSON 값을 서버 표현으로 변환.

// 클라이언트가 보냄
{ "variables": { "publishedAt": "2026-05-17T12:00:00Z" } }
 
// parseValue가 받는 값
parseValue("2026-05-17T12:00:00Z") → new Date(...)

2) parseLiteral — 쿼리 문자열에 직접 박힌 값을 서버 객체로

쿼리 안에 리터럴로 적힌 값을 변환.

{ posts(after: "2026-05-17T12:00:00Z") { id } }

여기서 "2026-05-17T12:00:00Z"AST node(StringValue)로 들어온다.

parseLiteral(ast) {
  if (ast.kind !== Kind.STRING) throw new Error('expected string')
  return new Date(ast.value)
}

parseValue와 분리되어 있나: variables는 JSON이라 타입이 이미 결정되어 있다(string·number·boolean·null). 리터럴은 GraphQL ASTIntValue·FloatValue·StringValue·BooleanValue·EnumValue·ListValue·ObjectValue다른 노드 종류가 있다. 두 입력은 모양이 다르므로 별개의 파서가 필요.

3) serialize — 서버 객체를 wire 형식으로

서버 메모리의 객체를 클라이언트로 보낼 JSON 값으로.

serialize(value) {
  if (value instanceof Date) return value.toISOString()
  if (typeof value === 'string') return new Date(value).toISOString()  // 정규화
  throw new Error('DateTime cannot serialize: ' + value)
}

응답 JSON:

{ "data": { "post": { "publishedAt": "2026-05-17T12:00:00.000Z" } } }

What — DateTime 구현 예제

graphql-js로 직접 정의

import { GraphQLScalarType, Kind } from 'graphql'
 
export const DateTime = new GraphQLScalarType({
  name: 'DateTime',
  description: 'RFC 3339 / ISO-8601 date-time string',
  specifiedByURL: 'https://datatracker.ietf.org/doc/html/rfc3339',
 
  serialize(value: unknown): string {
    if (value instanceof Date) return value.toISOString()
    if (typeof value === 'string') {
      const d = new Date(value)
      if (isNaN(d.getTime())) throw new TypeError('invalid date string')
      return d.toISOString()
    }
    throw new TypeError('DateTime cannot serialize: ' + value)
  },
 
  parseValue(value: unknown): Date {
    if (typeof value !== 'string') throw new TypeError('DateTime expects string')
    const d = new Date(value)
    if (isNaN(d.getTime())) throw new TypeError('invalid date string')
    return d
  },
 
  parseLiteral(ast): Date {
    if (ast.kind !== Kind.STRING) throw new TypeError('DateTime expects string literal')
    const d = new Date(ast.value)
    if (isNaN(d.getTime())) throw new TypeError('invalid date string')
    return d
  },
})

SDL에 등록

scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339")
 
type Post {
  id: ID!
  title: String!
  publishedAt: DateTime!
}

Resolver에서 사용

const resolvers = {
  DateTime,    // ⚠️ 반드시 resolver map에 등록해야 활성화
 
  Query: {
    post: (_, { id }, ctx) => ctx.db.post.findById(id),
    postsAfter: (_, { after }: { after: Date }, ctx) => {
      // after는 이미 Date 객체 (parseValue 통과)
      return ctx.db.post.findMany({ where: { publishedAt: { gt: after } } })
    },
  },
 
  Post: {
    publishedAt: (post) => post.publishedAt,    // Date 객체 그대로, serialize가 ISO로
  },
}

What — graphql-scalars 라이브러리

대부분의 custom scalar는 직접 만들 필요 없다. graphql-scalars(2018+, The Guild) 라이브러리가 70+ 표준 custom scalar를 제공한다.

npm install graphql-scalars
import { DateTimeResolver, EmailAddressResolver, URLResolver, JSONResolver } from 'graphql-scalars'
 
const resolvers = {
  DateTime: DateTimeResolver,
  EmailAddress: EmailAddressResolver,
  URL: URLResolver,
  JSON: JSONResolver,
}
scalar DateTime
scalar EmailAddress
scalar URL
scalar JSON
 
type User {
  email: EmailAddress!
  website: URL
  metadata: JSON
  createdAt: DateTime!
}

자주 쓰이는 종류:

Scalar의미검증
DateTimeRFC 3339 date-timeISO 포맷, 유효 날짜
DateRFC 3339 dateYYYY-MM-DD
TimeRFC 3339 timeHH:MM:SS
UUIDRFC 4122 UUID36자 UUID
EmailAddressRFC 5322 emailemail regex
URLRFC 3986 URLnew URL() 통과
JSON임의 JSON어떤 JSON 값이든
JSONObjectJSON object onlyobject 한정
BigIntint64안전한 큰 정수
Decimal정확한 소수부동소수 손실 없음
PositiveInt양의 정수n > 0
NonNegativeInt0 이상 정수n ≥ 0
IPv4/IPv6IP 주소RFC 검증
PhoneNumberE.164 전화번호+82-10-…

What — JSON scalar의 특수성

scalar JSON
 
type Post {
  metadata: JSON!
}

JSON scalar의 진실: type safety를 포기한다.

  • 클라이언트는 임의의 JSON 객체를 보낼 수 있다.
  • 코드 생성기는 *TS any*로 생성한다.
  • introspection은 내부 구조를 알려주지 않는다.

언제 정당화되나:

  1. 진짜로 임의 구조가 필요한 자리 — 사용자 정의 설정, 분석 이벤트.
  2. legacy 시스템의 자유 형식 데이터를 그대로 노출.

언제 안티패턴인가:

  • 구조가 고정되어 있는데 JSON으로 적었을 때. 차라리 전용 object type으로 분해.
# ❌ 안티
type Settings { config: JSON! }
 
# ✅ 명시
type Settings {
  theme: Theme!
  language: String!
  notifications: NotificationSettings!
}

What — Custom scalar 결정 트리


What-if — 자주 틀리는 패턴

함정 1) Resolver map에 scalar 등록 누락

scalar DateTime
const resolvers = {
  Query: { ... }
  // ⚠️ DateTime이 없음
}

이러면 parseValue·parseLiteral·serialize가 동작하지 않고 기본 fallback이 적용된다(graphql-js는 값을 그대로 통과 또는 에러). Date 객체가 그대로 JSON.stringify된 형태로 응답에 박힐 수 있다.

대응: scalar 정의를 추가하면 반드시 resolvers에 등록.

함정 2) parseValue만 만들고 parseLiteral 빼먹기

{ postsAfter(after: "2026-05-17T12:00:00Z") { id } }  # inline literal

variables로 보낼 때만 동작하고 inline literal에선 fail. 두 함수 모두 만들거나, 둘 다 같은 함수를 호출하도록 작성.

parseLiteral(ast) {
  if (ast.kind !== Kind.STRING) throw new TypeError('expected string')
  return this.parseValue(ast.value)
}

함정 3) serialize에서 에러 던지지 않기

serialize(value) {
  if (value instanceof Date) return value.toISOString()
  return value     // ⚠️ 임의 값을 통과
}

서버 객체가 예상 모양과 다르면 — 가령 null이 아닌데 Date가 아닌 string이 들어오면 — 클라이언트에 손상된 데이터가 가버린다. strict하게 throw해야 resolver bug를 빨리 발견.

함정 4) Date/DateTimeString으로 적은 채 잊기

type Post {
  publishedAt: String!     # 6개월 뒤 후회
}

나중에 DateTime으로 마이그레이션하려면 breaking change가 된다. 처음부터 진짜 의미에 맞는 scalar로.

함정 5) JSON scalar 남용

type User {
  preferences: JSON!     # ⚠️ 무엇이 들어있나?
}

3개월 뒤 아무도 구조를 모른다. 클라이언트는 defensive하게 옵셔널 체이닝 도배.

대응: 구조가 정해진 부분은 type으로, 진짜 자유 영역만 JSON으로.

type User {
  knownPrefs: UserKnownPrefs!
  customData: JSON           # 진짜 임의 영역만
}

함정 6) Timezone 처리 누락

serialize(value: Date) {
  return value.toString()    // ❌ locale 의존 — "Mon May 17 2026 ..."
}

Date.prototype.toString()서버 timezone에 의존. 클라이언트마다 다른 결과가 나갈 수 있다.

대응: toISOString()(UTC Z 끝맺음)으로 고정. timezone이 의미 있다면 별도 fieldRFC 9557 IXDTF(2026-05-17T12:00:00+09:00[Asia/Seoul]) 같은 명시적 포맷.


Insight — 흥미로운 이야기

@specifiedBy는 custom scalar를 진짜 표준으로 만든다”

표준 RFC와 링크된 custom scalar는 서버끼리 호환된다. 즉 GraphQL Yoga의 DateTime을 Apollo Server의 DateTime으로 마이그레이션해도 동작. @specifiedBy(url: "...") directive는 어느 표준을 따르는지 명시 — 내가 만든 ISO-8601남이 만든 ISO-8601같은 것임을 introspection으로 증명. 이게 graphql-scalars 라이브러리de facto 표준이 된 이유 — 모두 같은 RFC를 참조하므로.

“Custom scalar 등장 전 4년 동안 어떻게 살았나”

2015~2018 사이 GraphQL은 공식 표준 5종 외에 custom scalar를 만들 수 있다는 메커니즘은 있었지만, 재사용 가능한 라이브러리가 없었다. 모두 각자 DateTime을 다시 만들었다. Apollo, Relay, 사내 모든 서버가. 2018년 The Guildgraphql-scalars를 발표 — 처음으로 공통 구현. 지금 보면 너무 당연한데, 그때까지 모두가 reinvented. 결국 graphql-scalarsNode.js GraphQL 서버의 사실상 표준 의존성이 됐다.

BigInt scalar는 JS의 약점을 GraphQL이 메운 케이스”

JavaScript Number64-bit float라서 정수가 2^53(약 9000조) 이상에서 정밀도 손실. Twitter는 이게 이유로 ID를 string으로 보낸다("id_str": "..." field 별도). GraphQL은 spec에 BigInt가 없지만graphql-scalarsBigInt문자열로 wire에 적고 BigInt 객체로 서버 메모리 표현. 언어 차원의 약점을 application-level scalar가 메운 사례.

JSON scalar는 anti-pattern이라는 진영과 pragmatic이라는 진영이 있다”

강경 GraphQL 진영: “JSON scalar는 GraphQL의 type safety를 포기하는 항복” — 모든 것을 typed object로 모델하라. 실용 진영: “임의 메타데이터 한 자리는 JSON이 합리적” — 모든 자유 형식을 object로 분해하면 스키마가 수천 개의 모래알 타입이 된다. 정답은 어디 쓰느냐에 의존공개 API에선 JSON 피하기, 내부 도구·로그·이벤트는 JSON 정당화.


요약 + Mermaid

요약: custom scalar는 wire ↔ 서버 메모리 변환을 정의하는 3 함수(parseValue·parseLiteral·serialize)다. 직접 만들 수 있지만 graphql-scalars가 70+ 표준 구현을 제공 — 거의 모든 도메인에 맞는 것이 이미 있다. JSON scalar는 마지막 수단 — 구조가 있는 모든 것은 object type으로 분해하는 게 옳다. 다음 문서(07-schema-first-vs-code-first)는 이 스키마 전체를 어디서 소유할지 — SDL 파일이냐 코드냐 — 의 철학 차이를 다룬다.