🔷 GraphQL1. 스키마 & SDL02 · Scalar & Object Type

02 · Scalar & Object Type

이 문서가 답하는 질문: GraphQL의 표준 scalar는 무엇이고, object type과 어떻게 결합되며, IDString은 어떻게 다른가? 한 줄 답 (Pyramid Top): “표준 scalar는 Int·Float·String·Boolean·ID 5종뿐이고, 그 외 모든 leaf 값은 custom scalar거나 잘못 모델링된 object다.”


Why — 왜 타입을 명시적으로 가르는가

GraphQL의 모든 값은 두 부류 중 하나다:

  1. Leaf value (잎): 더 안으로 못 들어가는 최종 값 — scalarenum.
  2. 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) Booleantrue/false

type Notification {
  isRead: Boolean!
}

null이 가능하면 3상 상태가 된다(true / false / null). 이게 의도라면 Boolean, 아니면 Boolean!.

5) ID — 식별자 (serialize as String)

type User {
  id: ID!
}

IDwire 상에서는 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) IDString으로

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! }

03-interface-union-enum.

함정 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이지만 의미는 식별자인 ID scalar가 들어갔다. 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·email·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)으로. IDString과 wire 호환이지만 정규화 캐시 key라는 의미적 차이를 가진다. 다음 문서(03-interface-union-enum)는 composite의 나머지 — 다형성을 다룬다.