04 · Input Type & Argument
이 문서가 답하는 질문: 왜 GraphQL은
input과type을 분리했고, mutation에는 왜 input wrapper 패턴이 사실상 표준인가? 한 줄 답 (Pyramid Top): “인풋과 아웃풋의 모양은 서로 다르고, GraphQL은 그것을input과type키워드로 언어 차원에서 분리한다 — 같은 이름을 양쪽에서 쓸 수 없다.”
Why — 왜 인풋과 아웃풋을 분리하는가
다음 코드를 보자.
type User {
id: ID!
name: String!
createdAt: String!
posts: [Post!]! # ← 다른 객체로의 참조
followerCount: Int! # ← 계산된 필드
}이 User를 그대로 mutation 인자로 받을 수 있을까?
type Mutation {
createUser(user: User!): User! # ❌ valid GraphQL이 아님
}문제:
- 클라이언트가
posts를 보낼 수 있나? — Post는 별도 객체, 인자로 전달 의미 불명. followerCount를 보낼 수 있나? — 서버 계산 필드, 클라이언트 전달 의미 없음.id와createdAt을 보내야 하나? — 서버가 생성하는 값.
아웃풋과 인풋은 모양이 다르다. 아웃풋은 결과고, 인풋은 입력이다. 같은 도메인 객체라도 서로 다른 부분 집합과 다른 모양을 가진다.
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 A가 input B를 갖고 B가 A를 가지면 서버 검증에서 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!
}왜 이 패턴이 사실상 표준인가:
- Mutation 인자가 늘어나도 시그니처가 변하지 않는다 — 새 옵션은 input에 backward compatible 필드 추가.
- 클라이언트 코드 생성기가 typed input 객체를 만들기 쉽다.
- 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!
}UpdatePostInput은 id를 제외하고 모두 nullable — 클라이언트가 변경할 필드만 보내는 PATCH 의미.
What-if — 자주 틀리는 패턴
함정 1) Object type을 argument로
type Address {
street: String!
}
type Mutation {
createUser(address: Address!): User! # ❌
}스키마 등록 단계에서 fail. Address는 output 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.
참고: ID는 String과 wire 호환이지만 type system에서는 별개의 타입이다.
Insight — 흥미로운 이야기
“Input/Output 분리는 OpenAPI에는 없는 결정”
OpenAPI(REST 스펙)는 같은 schema object를 request body와 response에 모두 쓸 수 있다. 결과는 두 가지 — 명시적으로 다른 모양을 적기 번거롭다 → 많은 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)는 스키마와 쿼리의 동작을 바꾸는 또 다른 도구를 다룬다.