02 · Scalar & Object Type
이 문서가 답하는 질문: GraphQL의 표준 scalar는 무엇이고, object type과 어떻게 결합되며,
ID와String은 어떻게 다른가? 한 줄 답 (Pyramid Top): “표준 scalar는Int·Float·String·Boolean·ID5종뿐이고, 그 외 모든 leaf 값은 custom scalar거나 잘못 모델링된 object다.”
Why — 왜 타입을 명시적으로 가르는가
GraphQL의 모든 값은 두 부류 중 하나다:
- Leaf value (잎): 더 안으로 못 들어가는 최종 값 — scalar나 enum.
- Composite value (조립체): 안으로 더 들어갈 수 있는 값 — object, interface, union.
응답 트리는 루트가 composite이고 잎이 scalar인 트리다. 클라이언트가 어디까지 파고들지를 선택할 수 있는 이유는 composite은 selection set이 필수, scalar는 selection set 불가라는 규칙 때문이다.
query {
user(id: "1") { # composite — { } 필요
id # scalar — { } 불가
name # scalar
posts { # composite (list of object)
title # scalar
}
}
}만약 scalar/composite 구분이 없었다면 어디까지 selection set이 필요한지를 결정할 방법이 없다.
How — 표준 scalar 5종
GraphQL Spec October 2021 §3.5.1은 built-in scalar 정확히 5종만 정의한다.
1) Int — 32-bit signed integer
범위: -2^31 ~ 2^31 - 1 (대략 ±21억).
type Product {
stockCount: Int!
}주의: JavaScript Number는 64-bit float이지만 GraphQL Int는 32-bit. 주문 번호처럼 21억 넘는 값은 Int로 쓰면 오버플로한다. 그 경우 String (BigInt-as-string 패턴)이거나 custom scalar BigInt.
2) Float — IEEE 754 double precision
type Order {
amount: Float!
}돈에 Float은 거의 항상 잘못된 선택이다. 0.1 + 0.2 !== 0.3 문제. 돈은 String(decimal) 또는 custom scalar Decimal/Money로.
3) String — UTF-8 sequence of characters
type User {
name: String!
}가장 자주 쓰는 scalar. 임의의 텍스트를 담는다.
4) Boolean — true/false
type Notification {
isRead: Boolean!
}null이 가능하면 3상 상태가 된다(true / false / null). 이게 의도라면 Boolean, 아니면 Boolean!.
5) ID — 식별자 (serialize as String)
type User {
id: ID!
}ID는 wire 상에서는 String과 동일하지만 의미적 구분을 위해 존재한다.
ID필드는 human-readable 의미가 없는 식별자임을 선언한다.- 클라이언트 캐시(Apollo·Relay)는
id/ID!필드를 normalize key로 사용한다. - 서버는
ID를 받을 때 String도 Int도 둘 다 받아 String으로 강제해야 한다 (spec §3.5.5).
# 둘 다 valid
query { user(id: "abc-123") { name } }
query { user(id: 42) { name } } # Int 입력도 String "42"로 변환What — Object Type 구성 요소
Field
type User {
id: ID!
name: String!
email: String
}각 field는 이름: Type. Type은 scalar거나 composite, 그 위에 !/[]가 붙는다.
Field with arguments
type User {
posts(
limit: Int = 20
cursor: String
orderBy: PostOrder = CREATED_AT_DESC
): [Post!]!
}중요한 통찰: GraphQL의 인자는 root query에만 있는 게 아니라 모든 field에 있을 수 있다. user.posts(limit: 10)처럼 깊은 위치의 field도 인자를 받는다.
Field description
type User {
"사용자가 회원가입한 ISO-8601 UTC 시각"
createdAt: String!
}description은 introspection으로 노출 → GraphiQL·Apollo Studio가 자동 문서화에 사용.
Field deprecation
type User {
name: String! @deprecated(reason: "use fullName instead")
fullName: String!
}@deprecated는 동작은 그대로 두고 introspection에 표시만 한다 → 05-directives 참조.
전체 예제 — e-commerce 도메인
type Product {
id: ID!
sku: String!
title: String!
price: String! # ⚠️ Float 대신 String (decimal)
stockCount: Int!
isAvailable: Boolean!
images(limit: Int = 5): [Image!]!
category: Category!
}
type Image {
id: ID!
url: String!
altText: String
width: Int!
height: Int!
}
type Category {
id: ID!
name: String!
parent: Category # 자기 참조, nullable (루트 카테고리는 없음)
children: [Category!]!
}
type Query {
product(id: ID!): Product
products(
categoryId: ID
limit: Int = 20
offset: Int = 0
): [Product!]!
}What-if — 자주 틀리는 모델링
함정 1) 돈을 Float으로
type Order {
amount: Float! # ❌
}반올림 오차로 1원 차이가 생긴다. 결제·정산에서 치명적.
대응:
type Order {
amount: String! # "12345.67" 형태
# 또는 custom scalar
amount: Decimal!
}함정 2) 큰 정수를 Int로
type Order {
orderNumber: Int! # ❌ 21억 넘으면 오버플로
}대응: String ("20260517000123456") 또는 custom scalar BigInt.
함정 3) ID를 String으로
type User {
id: String! # ❌ 의미는 같지만 캐시가 안 됨
}Apollo Client·Relay는 id: ID! field가 있어야 자동으로 정규화 캐시 key를 만든다. String!이면 캐시가 그 객체를 전역적으로 식별 못해 화면마다 별개 인스턴스가 된다.
대응: 식별자는 항상 ID!.
함정 4) 날짜를 String으로 가벼이
type Post {
publishedAt: String! # ⚠️ 포맷이 보장 안 됨
}String!은 어떤 포맷의 문자열인지를 클라이언트에게 알리지 않는다. ISO-8601? Unix epoch? Locale 의존?
대응: custom scalar DateTime으로 포맷을 스키마 차원에서 강제 → 06-custom-scalars.
함정 5) Enum 대신 String
type User {
role: String! # ⚠️ "admin"·"ADMIN"·"Admin"·"superadmin"... 다 valid
}대응:
enum Role { ADMIN EDITOR READER }
type User { role: Role! }함정 6) 한 객체에 100개 field 몰아넣기 (god object)
type User {
# ... 80 fields including settings, preferences, billing, ...
}GraphQL은 클라이언트가 필요한 것만 가져가니까 괜찮지 않냐고 생각하기 쉽지만:
- introspection 비용이 커진다 (스키마 문서 무거움)
- 어떤 화면이 어떤 필드를 쓰는지 트래킹이 어려워진다
- N+1 위험 (각 field가 다른 resolver를 거치므로)
대응: 관심사를 나눠 sub-object로 분리.
type User {
id: ID!
profile: UserProfile!
settings: UserSettings!
billing: BillingInfo!
}Insight — 흥미로운 이야기
“
ID라는 scalar는 정규화 캐시를 가능하게 만든 디자인 결정이다”Relay(2015, Facebook)는 전역적으로 unique한 ID가 있어야 클라이언트 캐시가 동작한다고 가정했다. 그 결과 GraphQL spec에 전송 시 String이지만 의미는 식별자인
IDscalar가 들어갔다. Apollo Client(2016)는 이 가정을 그대로 채택 —__typename+id두 필드로 전역 캐시 key를 만든다. Relay-style global ID(User:abc123처럼 typename을 prefix로 base64 인코딩)는 spec이 강제하지 않지만 de facto 관례가 됐다. 그래서String!이 아니라ID!를 적는 한 줄이 클라이언트 캐시 정책 전체를 결정한다.
“표준 scalar가 5종밖에 없는 건 의도된 미니멀리즘”
JSON Schema는
format을 30종 이상 정의한다(date-time·uri·uuid…). GraphQL은 일부러 안 했다. 이유: spec이 정의한 포맷은 깨고 바꿀 수 없다. 만약Date가 spec scalar였다면 언어별 Date 표현과 timezone 처리 차이가 spec 레벨 호환성 문제가 된다. 대신 custom scalar를 1급 시민으로 만들었다 — 5종 표준은 최소 합의, 나머지는 팀이 결정. 이게 graphql-scalars(2018+, ~70개 standard custom scalars) 라이브러리가 등장한 이유.
요약 + Mermaid
요약: GraphQL value는 leaf(scalar·enum)와 composite(object·interface·union)로 나뉜다. 표준 scalar는 5종 —
Int·Float·String·Boolean·ID. 돈·날짜·큰 수는 반드시 다른 표현(String 또는 custom scalar)으로.ID는 String과 wire 호환이지만 정규화 캐시 key라는 의미적 차이를 가진다. 다음 문서(03-interface-union-enum)는 composite의 나머지 — 다형성을 다룬다.