06 · Custom Scalar
이 문서가 답하는 질문: 표준 5종 스칼라로 충분하지 않을 때 어떻게 custom scalar를 만들고,
parseValue·parseLiteral·serialize세 함수는 각각 언제 호출되는가? 한 줄 답 (Pyramid Top): “custom scalar는 wire 표현 ↔ 서버 메모리 표현 변환을 정의하는 3 함수(parseValue·parseLiteral·serialize)이고, 표준에 없는 모든 leaf 타입(DateTime·JSON·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 검증은?
}문제:
- 스키마에 포맷이 안 적혀 있다 — 클라이언트가 별도 문서를 봐야 한다.
- 서버가 검증을 안 한다 —
"not-an-email"이 통과한다. - 클라이언트 타입 생성기가 도와줄 수 없다 — TS
string으로 떨어진다. - 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 AST라 IntValue·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-scalarsimport { 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 | 의미 | 검증 |
|---|---|---|
DateTime | RFC 3339 date-time | ISO 포맷, 유효 날짜 |
Date | RFC 3339 date | YYYY-MM-DD |
Time | RFC 3339 time | HH:MM:SS |
UUID | RFC 4122 UUID | 36자 UUID |
EmailAddress | RFC 5322 email | email regex |
URL | RFC 3986 URL | new URL() 통과 |
JSON | 임의 JSON | 어떤 JSON 값이든 |
JSONObject | JSON object only | object 한정 |
BigInt | int64 | 안전한 큰 정수 |
Decimal | 정확한 소수 | 부동소수 손실 없음 |
PositiveInt | 양의 정수 | n > 0 |
NonNegativeInt | 0 이상 정수 | n ≥ 0 |
IPv4/IPv6 | IP 주소 | RFC 검증 |
PhoneNumber | E.164 전화번호 | +82-10-… |
What — JSON scalar의 특수성
scalar JSON
type Post {
metadata: JSON!
}JSON scalar의 진실: type safety를 포기한다.
- 클라이언트는 임의의 JSON 객체를 보낼 수 있다.
- 코드 생성기는 *TS
any*로 생성한다. - introspection은 내부 구조를 알려주지 않는다.
언제 정당화되나:
- 진짜로 임의 구조가 필요한 자리 — 사용자 정의 설정, 분석 이벤트.
- 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 DateTimeconst 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 literalvariables로 보낼 때만 동작하고 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/DateTime을 String으로 적은 채 잊기
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이 의미 있다면 별도 field나 RFC 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 Guild가graphql-scalars를 발표 — 처음으로 공통 구현. 지금 보면 너무 당연한데, 그때까지 모두가 reinvented. 결국graphql-scalars는 Node.js GraphQL 서버의 사실상 표준 의존성이 됐다.
“
BigIntscalar는 JS의 약점을 GraphQL이 메운 케이스”JavaScript
Number는 64-bit float라서 정수가 2^53(약 9000조) 이상에서 정밀도 손실. Twitter는 이게 이유로 ID를 string으로 보낸다("id_str": "..."field 별도). GraphQL은 spec에BigInt가 없지만 —graphql-scalars의BigInt가 문자열로 wire에 적고 BigInt 객체로 서버 메모리 표현. 언어 차원의 약점을 application-level scalar가 메운 사례.
“
JSONscalar는 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+ 표준 구현을 제공 — 거의 모든 도메인에 맞는 것이 이미 있다.JSONscalar는 마지막 수단 — 구조가 있는 모든 것은 object type으로 분해하는 게 옳다. 다음 문서(07-schema-first-vs-code-first)는 이 스키마 전체를 어디서 소유할지 — SDL 파일이냐 코드냐 — 의 철학 차이를 다룬다.