GraphQL 명세 ②: 우리가 작성하는 쿼리 언어(Language)

GraphQL 명세 ②: 우리가 작성하는 쿼리 언어(Language)

지난 편에서 GraphQL 명세가 어떤 문서이고 왜 읽어야 하는지 함께 살펴봤는데요. 이번 편부터는 본격적으로 명세 본문으로 들어갑니다. 첫 번째 본문 장은 제2장 Language, 그러니까 우리가 클라이언트에서 작성해 서버로 보내는 그 쿼리 텍스트의 문법을 다루는 장입니다.

평소에 별생각 없이 { user { name } } 같은 쿼리를 작성하셨겠지만, 이 짧은 텍스트 안에도 명세가 엄밀하게 정의한 문법 규칙이 빼곡히 들어 있는데요. 이번 편에서는 GraphQL 문서가 어떤 부품들로 조립되는지, 연산부터 디렉티브까지 하나씩 분해해보겠습니다.

GraphQL 문서는 정의들의 모음입니다

우리가 서버로 보내는 쿼리 텍스트 전체를 명세에서는 문서(Document) 라고 부릅니다. 그리고 하나의 문서는 여러 개의 정의(Definition) 로 이루어집니다. 정의에는 크게 두 종류가 있는데요. 실제로 실행할 내용을 담은 실행 정의(executable definition)와 스키마를 기술하는 타입 시스템 정의(type system definition)입니다.

클라이언트가 보내는 문서에는 보통 실행 정의만 들어갑니다. 실행 정의는 다시 연산(Operation)프래그먼트(Fragment) 로 나뉘는데요. 아래 문서에는 연산 하나와 프래그먼트 하나가 들어 있습니다.

query GetUser {
  user(id: "1") {
    ...userFields
  }
}

fragment userFields on User {
  name
  email
}

이렇게 하나의 문서 안에 여러 정의가 나란히 놓일 수 있다는 점이 출발점입니다. 이제 이 부품들을 하나씩 들여다보겠습니다.

연산에는 세 가지 타입이 있습니다

연산은 서버에 “무엇을 해달라”고 요청하는 단위입니다. 명세는 세 가지 연산 타입을 정의하는데요. 데이터를 읽는 query, 데이터를 변경하는 mutation, 그리고 서버의 변화를 실시간으로 구독하는 subscription입니다.

query {
  posts {
    title
  }
}

mutation {
  createPost(title: "안녕하세요") {
    id
  }
}

가장 흔히 쓰는 query는 사실 축약형을 허용합니다. 문서에 연산이 하나뿐이고 그게 query라면, query라는 키워드와 연산 이름을 통째로 생략하고 중괄호만 쓸 수 있습니다.

{
  posts {
    title
  }
}

이 축약형(query shorthand)이 바로 GraphiQL에서 처음 쿼리를 짤 때 흔히 보는 그 모양인데요. 다만 변수를 쓰거나 연산이 여러 개일 때는 축약형을 쓸 수 없고, query GetPosts처럼 이름을 붙여줘야 합니다. 연산에 이름을 붙이면 디버깅할 때 어떤 연산이 실행됐는지 알아보기 쉽고, 서버 로그나 모니터링 도구에서도 구분이 되니 실무에서는 이름을 붙이는 편이 좋습니다.

필드가 응답의 모양을 결정합니다

연산 안에서 우리가 실제로 요청하는 것은 필드(Field) 입니다. 필드는 “이 데이터를 달라”는 요청 하나하나를 가리키는데요. GraphQL의 핵심 아이디어가 바로 여기 있습니다. 요청한 필드의 구조가 그대로 응답의 구조가 된다는 점입니다.

{
  user(id: "1") {
    name
    posts {
      title
    }
  }
}

user 아래에 nameposts를 요청하면, 응답도 정확히 그 모양으로 돌아옵니다. posts 같은 필드는 또 그 아래에 하위 필드(title)를 가질 수 있어서, 필드는 얼마든지 중첩될 수 있습니다. 객체 타입을 반환하는 필드는 반드시 하위 필드를 명시해야 하고, 반대로 스칼라 타입을 반환하는 필드는 하위 필드를 가질 수 없습니다. 이 규칙을 어기면 다음 편 이후에 다룰 검증(Validation) 단계에서 걸러집니다.

인자로 필드에 입력을 전달합니다

필드 옆 괄호 안에 들어가는 것이 인자(Argument) 입니다. 위 예제의 user(id: "1")에서 id: "1"이 바로 인자인데요. 함수에 매개변수를 넘기듯, 필드가 동작하는 데 필요한 입력을 전달하는 역할을 합니다.

{
  posts(first: 10, orderBy: CREATED_AT) {
    title
  }
}

인자는 이름과 값의 쌍으로 쓰며, 콤마로 여러 개를 나열할 수 있습니다. 인자의 순서는 의미가 없어서 posts(orderBy: CREATED_AT, first: 10)이라고 써도 똑같이 동작합니다. 인자 값으로는 정수, 실수, 문자열, 불리언, 열거형(CREATED_AT 같은), 리스트, 객체 등 명세가 정의한 입력 값들이 올 수 있는데, 이 값들의 타입은 다음 편 타입 시스템(Type System)에서 자세히 다루겠습니다.

별칭으로 같은 필드를 여러 번 부릅니다

같은 필드를 조건만 바꿔 두 번 요청하고 싶을 때가 있습니다. 그런데 응답 객체의 키는 필드 이름을 그대로 따르기 때문에, 같은 이름의 필드를 두 번 쓰면 키가 충돌합니다. 이때 쓰는 것이 별칭(Alias) 입니다.

{
  admin: user(id: "1") {
    name
  }
  guest: user(id: "2") {
    name
  }
}

필드 이름 앞에 별칭: 형태로 붙여주면, 응답에서는 그 별칭이 키가 됩니다.

응답
{
  "data": {
    "admin": { "name": "관리자" },
    "guest": { "name": "손님" }
  }
}

별칭이 없었다면 user라는 키 하나에 두 결과를 담을 수 없어 충돌했을 텐데, 별칭 덕분에 adminguest로 깔끔하게 나뉘었습니다.

프래그먼트로 중복을 줄입니다

여러 곳에서 똑같은 필드 묶음을 반복해서 요청하다 보면 쿼리가 장황해집니다. 이때 필드 묶음을 따로 떼어 재사용할 수 있게 해주는 것이 프래그먼트(Fragment) 입니다. 맨 처음 예제에서 봤던 userFields가 바로 프래그먼트였는데요.

{
  admin: user(id: "1") {
    ...userFields
  }
  guest: user(id: "2") {
    ...userFields
  }
}

fragment userFields on User {
  name
  email
  avatarUrl
}

fragment 이름 on 타입 형태로 정의하고, 쓰는 쪽에서는 ...이름처럼 펼침 연산자(spread)로 끼워 넣습니다. on User 부분은 이 프래그먼트가 User 타입에 적용된다는 뜻인데요. 그래서 프래그먼트는 아무 데나 끼울 수 있는 게 아니라, 그 타입을 가진 필드 안에서만 펼칠 수 있습니다.

이름을 붙이지 않고 그 자리에서 바로 펼치는 인라인 프래그먼트(inline fragment) 도 있습니다. 인라인 프래그먼트는 특히 인터페이스나 유니온처럼 여러 타입이 섞여 나올 수 있는 필드에서, 타입별로 다른 필드를 요청할 때 유용합니다.

{
  search(text: "graphql") {
    ... on Post {
      title
    }
    ... on User {
      name
    }
  }
}

search가 Post일 때는 title을, User일 때는 name을 달라는 뜻입니다. 이 패턴은 타입 시스템에서 유니온과 인터페이스를 다룰 때 다시 만나게 됩니다.

변수로 쿼리를 동적으로 만듭니다

지금까지는 id: "1"처럼 값을 쿼리 안에 직접 박아 넣었는데요. 실제 애플리케이션에서는 이 값이 사용자 입력이나 상태에 따라 바뀌어야 합니다. 매번 쿼리 문자열을 새로 조립하는 건 위험하고 번거로우니, 명세는 변수(Variable) 를 제공합니다.

query GetUser($id: ID!) {
  user(id: $id) {
    name
  }
}

변수는 $이름 형태로 쓰고, 연산 이름 옆 괄호에서 $id: ID!처럼 타입과 함께 선언합니다. 여기서 ID!의 느낌표는 이 변수가 반드시 있어야 한다는 뜻인데, 이런 Non-Null 표기도 타입 시스템에서 다룹니다. 실제 값은 쿼리와 별도로 JSON 형태로 함께 보냅니다.

variables
{
  "id": "1"
}

이렇게 쿼리 구조와 입력 값을 분리하면, 같은 쿼리를 값만 바꿔 재사용할 수 있고 문자열을 직접 이어 붙이다 생기는 실수도 막을 수 있습니다. 변수에는 기본값도 줄 수 있어서, $first: Int = 10처럼 선언하면 값을 안 넘겼을 때 10이 쓰입니다.

디렉티브로 실행 방식을 조정합니다

마지막 부품은 디렉티브(Directive) 입니다. 디렉티브는 @이름 형태로 필드나 프래그먼트에 붙어서, 실행 방식을 그 자리에서 살짝 바꾸라고 지시하는 표시인데요. 명세는 모든 GraphQL 서버가 기본으로 지원해야 하는 디렉티브로 @skip@include를 정의합니다.

query GetUser($withEmail: Boolean!) {
  user(id: "1") {
    name
    email @include(if: $withEmail)
  }
}

@include(if: ...)는 조건이 참일 때만 그 필드를 포함하고, @skip(if: ...)는 반대로 참일 때 그 필드를 건너뜁니다. 위 쿼리에서 withEmailfalse면 응답에 email이 아예 빠집니다. 이렇게 클라이언트의 상황에 따라 어떤 필드를 받을지를 쿼리 한 줄로 제어할 수 있는 거죠.

디렉티브는 GraphQL을 확장하는 강력한 장치라서, 서버가 자체 디렉티브를 정의해 캐싱이나 권한 같은 동작을 붙이기도 합니다. 스키마 쪽에 붙는 @deprecated@specifiedBy 같은 디렉티브도 있는데, 이쪽은 타입 시스템에서 만나보겠습니다.

마치며

이번 편에서는 GraphQL 명세 제2장 Language를 따라, 우리가 작성하는 쿼리 문서가 어떤 부품으로 조립되는지 살펴봤습니다. 하나의 문서는 연산과 프래그먼트로 이루어지고, 연산 안에서는 필드로 데이터를 요청하며, 인자로 입력을 전달하고, 별칭으로 이름 충돌을 피하고, 프래그먼트로 중복을 줄이고, 변수로 동적인 값을 다루며, 디렉티브로 실행을 조정한다는 큰 그림을 그려봤는데요.

여기서 자꾸 “이건 타입 시스템에서 다룬다”는 말이 나왔다는 걸 눈치채셨을 겁니다. 우리가 작성한 쿼리는 결국 서버가 제공하는 스키마라는 틀 위에서만 의미를 갖기 때문인데요. 그래서 다음 편 타입 시스템(Type System)에서는 그 틀, 즉 서버가 어떤 타입으로 스키마를 정의하는지를 파고들겠습니다. 그 전에 실제 쿼리를 손으로 작성해보고 싶으시다면 GraphQL API를 간단하게 호출하는 방법이 좋은 연습이 됩니다.

문법의 정확한 규칙이 궁금하시다면 GraphQL September 2025 명세의 Language 장을 함께 펼쳐보세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord