🔷 GraphQL1. 스키마 & SDL04 · Input Type & Argument

04 · Input Type & Argument

이 문서가 답하는 질문: 왜 GraphQL은 inputtype을 분리했고, mutation에는 왜 input wrapper 패턴이 사실상 표준인가? 한 줄 답 (Pyramid Top): “인풋과 아웃풋의 모양은 서로 다르고, GraphQL은 그것을 inputtype 키워드로 언어 차원에서 분리한다 — 같은 이름을 양쪽에서 쓸 수 없다.”


Why — 왜 인풋과 아웃풋을 분리하는가

다음 코드를 보자.

type User {
  id: ID!
  name: String!
  createdAt: String!
  posts: [Post!]!         # ← 다른 객체로의 참조
  followerCount: Int!     # ← 계산된 필드
}

User그대로 mutation 인자로 받을 수 있을까?

type Mutation {
  createUser(user: User!): User!     # ❌ valid GraphQL이 아님
}

문제:

  1. 클라이언트가 posts를 보낼 수 있나? — Post는 별도 객체, 인자로 전달 의미 불명.
  2. followerCount를 보낼 수 있나? — 서버 계산 필드, 클라이언트 전달 의미 없음.
  3. idcreatedAt을 보내야 하나? — 서버가 생성하는 값.

아웃풋과 인풋은 모양이 다르다. 아웃풋은 결과고, 인풋은 입력이다. 같은 도메인 객체라도 서로 다른 부분 집합과 다른 모양을 가진다.

GraphQL은 이것을 언어 차원에서 분리한다:

input CreateUserInput {
  name: String!
  email: String!
}
 
type Mutation {
  createUser(input: CreateUserInput!): User!
}

규칙:

  • input 키워드로 정의된 타입은 오직 argument 위치에만 올 수 있다.
  • type 키워드로 정의된 object type은 오직 응답 필드 위치에만 올 수 있다.
  • 둘은 서로 호환 불가.

스키마 등록 시 검증되어 어기면 서버 구동이 실패.


How — Input의 형태

1) 단일 scalar/enum 인자

type Query {
  user(id: ID!): User
  posts(limit: Int = 20, status: PostStatus): [Post!]!
}

가장 단순한 형태. 인자가 1~2개면 굳이 input wrapper를 만들지 않는다.

2) 리스트 인자

type Query {
  postsByIds(ids: [ID!]!): [Post!]!
  postsByTags(tags: [String!] = []): [Post!]!
}

리스트도 인자로 valid. default가 빈 배열일 때는 [String!] = []처럼 적는다.

3) input type — 복잡한 인자 묶음

input PostFilter {
  authorId: ID
  tags: [String!]
  publishedAfter: String
  publishedBefore: String
  status: PostStatus = PUBLISHED
}
 
type Query {
  posts(filter: PostFilter, limit: Int = 20): [Post!]!
}

input type의 필드도 각자 default value를 가질 수 있다. status처럼.

4) 중첩 input

input AddressInput {
  street: String!
  city: String!
  zip: String!
}
 
input CreateUserInput {
  name: String!
  email: String!
  address: AddressInput          # input은 input을 가질 수 있다
  altAddresses: [AddressInput!]
}

input 안에 다른 input은 가능. 다만 순환 참조는 불가능input Ainput B를 갖고 BA를 가지면 서버 검증에서 reject.

이유: 인풋은 값 트리여야 하고, 무한 깊이 입력은 DoS 표면이 된다. (참조 의미를 표현하고 싶다면 ID로 받고 서버가 lookup하는 게 옳다.)

5) Mutation input wrapper 패턴 (Relay 관례)

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!] = []
  clientMutationId: String       # idempotency 용 옵션
}
 
type CreatePostPayload {
  post: Post!
  clientMutationId: String
}
 
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

왜 이 패턴이 사실상 표준인가:

  1. Mutation 인자가 늘어나도 시그니처가 변하지 않는다 — 새 옵션은 input에 backward compatible 필드 추가.
  2. 클라이언트 코드 생성기typed input 객체를 만들기 쉽다.
  3. payload도 wrapper로 — 나중에 errors·warning 필드 추가가 자유롭다.

Relay·Apollo·Shopify·GitHub 모두 이 패턴을 따른다.


What — 구체 사양

Input field 규칙

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!] = []
  publishedAt: String = null      # nullable + default null
}
  • field에 argument불가능 — input field는 이지 함수가 아니다.
  • field type은 scalar·enum·input·이들의 list만 가능. object type 불가.
  • field에 default value 가능.
  • field에 @deprecated 가능 (spec 2021부터 input field도 deprecated 허용).

Default value 동작

input PostFilter {
  limit: Int = 20
}
  • 클라이언트가 limit을 안 보내면 서버는 20을 본다.
  • 클라이언트가 null을 명시적으로 보내면: spec상 null이 들어온다. default가 적용되지 않는다. ← 함정 자주 발생.
query A { posts(filter: { }) }              # limit = 20
query B { posts(filter: { limit: null }) }  # limit = null (not 20)

Argument 순서 무관

{ posts(limit: 10, status: PUBLISHED) }
{ posts(status: PUBLISHED, limit: 10) }    # 동일

GraphQL은 named argument only. 위치 인자(positional) 개념이 없다.

Variables — 클라이언트 쿼리 파라미터화

query GetPost($id: ID!, $withAuthor: Boolean = false) {
  post(id: $id) {
    title
    author @include(if: $withAuthor) { name }
  }
}
{
  "query": "...",
  "variables": { "id": "abc", "withAuthor": true }
}
  • variable 선언은 operation 위치에 적는다 (query GetPost(...)).
  • variable type은 반드시 input-compatible. User(object) 같은 건 불가, CreatePostInput은 가능.
  • variable은 default value를 가질 수 있다 ($withAuthor: Boolean = false).

전체 예제 — 게시판 CRUD

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!] = []
}
 
input UpdatePostInput {
  id: ID!
  title: String
  body: String
  tags: [String!]
}
 
input PostFilter {
  authorId: ID
  status: PostStatus
  tags: [String!]
}
 
type CreatePostPayload {
  post: Post!
}
 
type UpdatePostPayload {
  post: Post!
}
 
type DeletePostPayload {
  deletedId: ID!
}
 
type Query {
  posts(filter: PostFilter, limit: Int = 20, offset: Int = 0): [Post!]!
  post(id: ID!): Post
}
 
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
}

UpdatePostInputid를 제외하고 모두 nullable — 클라이언트가 변경할 필드만 보내는 PATCH 의미.


What-if — 자주 틀리는 패턴

함정 1) Object type을 argument로

type Address {
  street: String!
}
 
type Mutation {
  createUser(address: Address!): User!   # ❌
}

스키마 등록 단계에서 fail. Addressoutput type이라서 인자 자리에 못 온다.

대응: input AddressInput을 별도로 정의.

함정 2) 한 타입을 input/output 양쪽에서 재사용하려고

# ❌ valid GraphQL이 아님
type Address {
  street: String!
  city: String!
}
 
type User { address: Address }
type Mutation { createUser(address: Address): User }

분리해야 한다:

type Address { street: String! city: String! }
input AddressInput { street: String! city: String! }

같은 필드가 두 군데 적힌다는 중복의도된 비용이다 — 아웃풋과 인풋은 시간이 가면 모양이 달라진다.

함정 3) Default value와 null의 혼동

input Filter { limit: Int = 20 }
query { posts(filter: { limit: null }) }    # limit = null, not 20

서버 코드:

function resolvePosts(_, { filter }) {
  const limit = filter.limit ?? 20    // ✅ null도 default로 강제
}

스키마 default는 클라이언트가 필드를 생략한 경우에만 적용된다. 명시적 null은 명시적 의도로 해석.

함정 4) Input의 깊은 중첩 (DoS 표면)

input DeepFilter {
  and: [DeepFilter!]    # ❌ 자기 참조 input
  or: [DeepFilter!]
}

spec상 self-referential input은 cycle 검출되어 거절된다 (서버 구현마다 정확한 동작은 다름). 또한 깊이를 클라이언트가 제어하면 DoS 표면이 된다.

대응: 깊이를 고정 단계(예: 2단계)로 제한하거나, 쿼리 표현을 문자열 DSL로 단순화.

함정 5) 너무 많은 optional field

input UpdateUserInput {
  id: ID!
  name: String
  email: String
  bio: String
  avatarUrl: String
  preferences: PreferencesInput
  notifications: NotificationsInput
  privacy: PrivacyInput
  # ... 20+ nullable fields
}

PATCH 의미는 좋지만 어느 필드를 안 보낸 게 “변경 없음”이고 어느 게 “null로 설정”인지 헷갈린다. null vs undefined 구분이 wire 상으로는 동일(전송 안 됨이 곧 null).

대응: 큰 도메인은 작은 mutation으로 분리 — updateUserProfile, updateUserPreferences, updateUserPrivacy.

함정 6) Variables type을 query 안에서 잘못 적음

query GetPost($id: String!) {   # ⚠️ post(id: ID!)인데 String!로 적음
  post(id: $id) { title }
}

field 정의는 ID!인데 variable 선언은 String!type mismatch validation에서 reject.

참고: IDString과 wire 호환이지만 type system에서는 별개의 타입이다.


Insight — 흥미로운 이야기

“Input/Output 분리는 OpenAPI에는 없는 결정”

OpenAPI(REST 스펙)는 같은 schema objectrequest bodyresponse에 모두 쓸 수 있다. 결과는 두 가지 — 명시적으로 다른 모양을 적기 번거롭다 → 많은 OpenAPI 스키마가 입력엔 필요 없는 필드를 노출 + 서버 계산 필드를 인풋 페이로드에 적게 됨. GraphQL은 언어 차원에서 분리해서 비용을 강제했다 — 입력과 출력은 반드시 다른 타입으로 적어야 한다. 처음엔 중복처럼 느껴지지만, 6개월 뒤 output에 추가한 필드가 input을 오염시키지 않는다는 사실이 그 비용을 정당화한다.

“Mutation payload가 객체인 이유”

초기 GraphQL 예제는:

type Mutation { createPost(...): Post! }

처럼 생성된 객체를 직접 반환했다. 그러나 시간이 지나면서 다음이 필요해진다:

  • mutation의 부분 실패 (post는 생성됐지만 알림 발송 실패)
  • side effect의 반환 (생성된 post + 변경된 user의 postCount)
  • 추후 추가 field

그래서 GitHub·Shopify가 payload wrapper를 표준화:

type CreatePostPayload {
  post: Post!
  user: User!
  userErrors: [UserError!]!
}

이 패턴은 Shopify GraphQL Design Tutorial(2018)이 권고하면서 de facto가 됐다.

clientMutationId는 Relay가 idempotency를 위해 도입했다”

Relay(2015)는 optimistic update + mutation 큐를 위해 클라이언트가 mutation에 ID를 붙이고, 서버가 그 ID를 응답에 echo하는 패턴을 표준화했다. 같은 mutation을 재시도해도 같은 ID서버가 dedupe 가능. 분산 시스템의 idempotency key와 같은 개념. 현대 GraphQL에서는 필수는 아니지만, mutation을 재시도해야 하는 모바일 환경에서 여전히 유용한 패턴.


요약 + Mermaid

요약: GraphQL은 인풋과 아웃풋을 언어 차원에서 분리한다 — input 키워드와 type 키워드는 서로 호환 불가. input은 scalar·enum·input의 list만 받고, object를 받지 못한다. Mutation은 input wrapper + payload wrapper 패턴이 사실상 표준 — 새 필드 추가가 backward compatible. 다음 문서(05-directives)는 스키마와 쿼리의 동작을 바꾸는 또 다른 도구를 다룬다.