GraphQL의 가장 핵심적인 기능 중 하나는 클라이언트가 필요한 데이터를 명확하게 요청할 수 있는 쿼리(Query) 문법이다.

GraphQL 쿼리를 사용하면 서버가 제공하는 데이터 구조 내에서 클라이언트가 원하는 데이터만 정확히 받아올 수 있다.

 

GraphQL 쿼리란?


GraphQL 쿼리는 클라이언트가 서버에 보내는 데이터 요청으로, REST API와는 다르게, GraphQL에서는 어떤 데이터가 필요한지 클라이언트가 명확히 정의할 수 있다.

 

예시
query {
  user(id: 1) {
    id
    name
    email
  }
}
  • 사용자 ID가 1인 유저의 ID, 이름, 이메일을 요청하는 쿼리이다.

 


쿼리 기본 구문


GraphQL 쿼리는 중괄호 {}를 사용해 요청할 필드를 명시하는 방식이다.

query GetUser {
  user(id: 1) {
    name
    email
  }
}
  • query: 쿼리 유형을 선언 (생략 가능)
  • GetUser: 쿼리 이름, 디버깅과 로깅에 유용
  • user: 서버에서 정의된 쿼리 이름
  • {}: 요청하고자 하는 하위 필드들

 

필드 선택과 별칭(Alias) 사용

GraphQL의 유용한 기능 중 하나는 필드 선택별칭(alias)이다.

query {
  user1: user(id: 1) { name }
  user2: user(id: 2) { name }
}

동일한 user 쿼리를 두 번 호출하지만, 별칭을 통해 응답 필드가 충돌하지 않도록 구성한다.

 

별칭이 유용한 경우는 다음과 같다.

  • 동일한 쿼리를 여러 번 호출할 때
  • 서로 다른 인수를 가진 동일 쿼리를 구분할 때
  • 클라이언트에서 결과를 구분 지을 필요가 있을 때

 


GraphQL 서버 스키마와의 상호작용


GraphQL은 서버 스키마(Schema)를 기준으로 동작하며, API에서 제공 가능한 데이터 구조와 그 관계를 정의한다.

 

스키마 예시
type User {
  id: ID!
  name: String!
  email: String!
}

type Query {
  user(id: ID!): User
  users: [User]
}
  • 스키마에는 객체 타입, 필드, 리턴 타입, 인수 등이 포함된다.

 

 

클라이언트 요청 예시
query {
  users {
    id
    name
  }
}
  • 요청한 필드(id, name)만 반환된다.

 

Introspection Query (스키마 조회)

GIntrospection Query는 GraphQL 서버의 스키마 정보를 조회할 수 있는 특별한 쿼리이다.

즉, "서버는 어떤 타입을 지원하나요?", "이 타입은 어떤 필드를 가지고 있나요?" 같은 질문을 클라이언트가 직접 물어볼 수 있게 해 준다.

 

이 방식은 다음과 같은 상황에서 유용하다.

  • GraphQL Playground, Apollo Studio, GraphiQL 같은 툴에서 스키마 자동 완성 기능
  • 코드 자동 생성 도구에서 타입 정보 수집
  • API 문서화 또는 스키마 분석
항목 설명
__type(name: "타입이름") 특정 타입에 대한 상세 조회
__schema 전체 스키마 구조, 루트 타입, 디렉티브 등 메타 정보 조회

 

 

Introspection Query 예시 (__schema)

📥 요청 (Query)
{
  __schema {
    types {
      name
      kind
      description
    }
    queryType {
      name
    }
    mutationType {
      name
    }
    subscriptionType {
      name
    }
    directives {
      name
      description
    }
  }
}
항목 설명
types 전체 타입 목록 (객체 타입, 스칼라, 유니온, 인터페이스 등)
queryType 루트 쿼리 타입의 이름 (보통 Query)
mutationType 루트 뮤테이션 타입의 이름 (없으면 null)
subscriptionType 루트 구독 타입 이름 (없으면 null)
directives 사용 가능한 디렉티브 목록 (@include, @skip 등)

 

📤 응답 (Response)
{
  "data": {
    "__schema": {
      "types": [
        {
          "name": "User",
          "kind": "OBJECT",
          "description": "A user in the system"
        },
        {
          "name": "Post",
          "kind": "OBJECT",
          "description": "A blog post"
        },
        {
          "name": "String",
          "kind": "SCALAR",
          "description": null
        }
      ],
      "queryType": {
        "name": "Query"
      },
      "mutationType": {
        "name": "Mutation"
      },
      "subscriptionType": null,
      "directives": [
        {
          "name": "include",
          "description": "Includes this field only when the `if` argument is true."
        },
        {
          "name": "skip",
          "description": "Skips this field when the `if` argument is true."
        }
      ]
    }
  }
}

 

 

Introspection Query 예시 (__type)

📥 요청 (Query)
{
  __type(name: "User") {
    name
    kind
    fields {
      name
      type {
        name
        kind
      }
    }
  }
}
  • 타입 이름과 종류 (OBJECT, SCALAR, INTERFACE 등)
  • 해당 타입이 가지는 필드들
  • 각 필드의 이름과 타입 정보
📤 응답 (Response)
{
  "data": {
    "__type": {
      "name": "User",
      "kind": "OBJECT",
      "fields": [
        {
          "name": "id",
          "type": {
            "name": null,
            "kind": "NON_NULL"
          }
        },
        {
          "name": "name",
          "type": {
            "name": "String",
            "kind": "SCALAR"
          }
        },
        {
          "name": "email",
          "type": {
            "name": "String",
            "kind": "SCALAR"
          }
        }
      ]
    }
  }
}
  • User 타입은 OBJECT
  • id, name, email이라는 필드가 있다.
  • name과 email은 String 스칼라 타입이다.
  • id는 NON_NULL 타입으로, null이 허용되지 않는 필드임을 나타낸다.

 


🔧 쿼리 구성 요소


GraphQL 쿼리는 여러 구성 요소로 이루어져 있으며, 각각은 유연한 데이터 요청을 가능하게 한다.

 

1️⃣ 필드 (Fields)

GraphQL의 가장 기본 단위는 필드이다.

클라이언트는 필드를 통해 필요한 데이터를 선택적으로 요청할 수 있다.

{
  user(id: 1) {
    id
    name
    email
  }
}
  • user는 쿼리의 루트 필드
  • { id, name, email }은 하위 필드
  • 중첩 필드를 통해 관계형 데이터를 계층적으로 요청 가능

 

2️⃣ 인수 (Arguments)

Arguments는 필드에 값을 전달할 때 사용된다.

REST API의 쿼리 파라미터처럼 작동하지만, GraphQL에서는 특정 필드에만 국한된다.

{
  post(id: 42) {
    title
    content
  }
}
  • id: 42는 post 필드에 전달될 인수
  • 서버에서는 이 인수를 기반으로 데이터를 필터링하거나 조회

⚠️ 모든 인수가 필수는 아니며, 스키마 정의에 따라 optional 할 수 있다.

 

 

3️⃣ 별칭 (Alias)

Alias는 동일한 필드를 여러 번 호출하거나, 응답 필드 이름을 커스터마이징 할 때 사용한다.

{
  first: user(id: 1) { name }
  second: user(id: 2) { name }
}

 

응답 결과
{
  "first": { "name": "Alice" },
  "second": { "name": "Bob" }
}

 

 같은 필드를 여러 조건으로 요청할 때 유용하다.

 

 

4️⃣ 변수 (Variables)

Variables를 사용하면 쿼리를 동적으로 재사용할 수 있다.

서버로 전송할 실제 값은 JSON 형식으로 분리되어 전달된다.

 

쿼리 작성
query GetUser($userId: ID!) {
  user(id: $userId) {
    name
  }
}

 

JSON 변수 전달
{
  "userId": 1
}

 

변수는 보안에도 유리하며, 쿼리를 재사용할 수 있어 유지보수에 좋다.

 

 

5️⃣ 프래그먼트 (Fragments)

Fragment는 여러 쿼리 또는 동일 쿼리 내에서 반복되는 필드를 정의해 재사용할 수 있도록 도와준다.

 

Fragment 정의 및 사용
fragment UserInfo on User {
  id
  name
  email
}

query {
  users {
    ...UserInfo
  }
}

 

코드 중복을 줄이고, 쿼리를 모듈화 하여 가독성과 유지보수성 향상

 

 

6️⃣ 인라인 프래그먼트 (Inline Fragments)

Interface나 Union 타입을 다룰 때 특정 타입에 따라 다른 필드를 요청할 수 있도록 도와준다.

{
  search(query: "GraphQL") {
    ... on User {
      name
    }
    ... on Post {
      title
    }
  }
}
  • search 필드는 User 또는 Post 타입일 수 있다
  • 타입에 따라 조건부 필드를 요청 가능

다형성(polymorphism)을 쿼리 수준에서 표현할 수 있는 강력한 기능이다.

 

 

7️⃣ 디렉티브 (Directives)

디렉티브는 쿼리의 실행 방식을 동적으로 제어한다.

가장 일반적인 디렉티브는 @include와 @skip이다.

query GetUser($showEmail: Boolean!) {
  user(id: 1) {
    name
    email @include(if: $showEmail)
  }
}
  • @include(if: true) -> 해당 필드 포함
  • @skip(if: true) -> 해당 필드 생략

 조건부 렌더링을 서버로 넘김으로써 네트워크 효율을 극대화할 수 있다.

 


내부 타입 시스템 소개


GraphQL은 내장 타입과 사용자 정의 타입을 통해 구조화된 데이터 요청을 지원한다.

 

사용자 정의 객체 타입(User-defined Object Types)

type Post {
  id: ID!
  title: String!
  content: String
}
  • 개발자가 직접 정의한 데이터 구조

 

내장 스칼라 타입

  • Int, Float, String, Boolean, ID

 

✍️ 내부 타입 정의 방식

GraphQL의 타입은 SDL(Schema Definition Language)을 통해 정의되며, 다음과 같은 방식으로 활용된다.

  • 객체 타입: type, field, resolver 조합
  • 내장 스칼라 타입: 별도 정의 없이 즉시 사용

 


⚙ 리졸버 (Resolver) 개념과 종류


리졸버란?

리졸버는 쿼리에서 요청한 데이터를 어떻게 가져올지를 정의하는 함수이다. 각 필드에 리졸버가 연결되어 있으며, 해당 필드가 요청될 때 실행된다.

 

📘  DataFetcher

  • 각 필드에 단독으로 데이터를 가져오는 함수
  • 단순한 API 응답 처리에 유용

 

📗 DataLoader

  • 여러 요청을 배치(batch)로 묶어 처리
  • 중복 데이터 요청을 줄이고, DB 호출 수를 최적화함
  • 캐싱도 지원

 


🚨  리졸버 사용 시 주의점: N + 1 문제


GraphQL을 사용하다 보면 흔히 마주하게 되는 성능 문제 중 하나가 바로 N + 1 문제이다. 이 문제는 특히 관계형 데이터를 다룰 때 발생하며, 아무런 최적화 없이 쿼리를 작성하면 불필요하게 많은 DB 쿼리가 실행되어 성능 저하로 이어질 수 있다.

 

N + 1 문제란?

N  + 1 문제란, 하나의 루트 쿼리에 대해 N개의 하위 요청이 발생하여 총 N + 1번의 데이터베이스 쿼리가 실행되는 비효율적인 상황을 의미한다.

 

이 문제는 GraphQL의 필드 단위 리졸버 구조에서 쉽게 발생한다. 루트 쿼리에서 여러 개의 항목을 가져오고, 각 항목에 대해 또 다른 데이터(연관된 객체)를 가져올게 될 때 문제는 시작된다.

 

예시: 게시글(Post)과 작성자(User) 데이터를 가져오는 쿼리
query {
  posts {
    id
    title
    author {
      id
      name
    }
  }
}
  • posts: 게시글 리스트
  • author: 각 게시글의 작성자

 

⚙️ 잘못된 리졸버 예시 (N + 1 발생)

@Component
public class PostResolver implements GraphQLResolver<Post> {

    @Autowired
    private UserRepository userRepository;

    public User getAuthor(Post post) {
        // 게시글마다 DB 호출 → N + 1 문제 발생
        return userRepository.findById(post.getAuthorId()).orElse(null);
    }
}
  • 게시글 10개 조회 -> findById()가 10번 호출됨
  • 총 쿼리 수: 1 (게시글) + 10 (작성자) = 11번

🚨게시글이 100개라면 101번의 쿼리 발생 -> 성능 급감..

 

💡 해결 방법: DataLoader 사용

 

DataLoader는 여러 개의 ID를 batch로 묶어 한 번에 쿼리하고, 결과를 매핑하여 반환한다.

GraphQL Java는 MappedBatchLoader를 사용한다.

 

1️⃣ MappedBatchLoader 정의

@Component
public class UserDataLoader implements MappedBatchLoader<Long, User> {

    @Autowired
    private UserRepository userRepository;

    @Override
    public CompletionStage<Map<Long, User>> load(Set<Long> userIds) {
        return CompletableFuture.supplyAsync(() -> {
            List<User> users = userRepository.findAllById(userIds);
            return users.stream()
                .collect(Collectors.toMap(User::getId, Function.identity()));
        });
    }
}
  • MappedBatchLoader는 요청된 ID들을 모아서 한 번에 처리하는 함수이다.
  • 결과는 Map<ID, User> 형태로 반환되며, 각 ID에 대해 빠르게 매칭할 수 있다.

 

2️⃣ DataLoaderRegistry 등록

@Component
public class DataLoaderRegistryFactory {

    public static final String USER_DATA_LOADER = "USER_DATA_LOADER";

    @Autowired
    private UserDataLoader userDataLoader;

    public DataLoaderRegistry create() {
        DataLoaderRegistry registry = new DataLoaderRegistry();
        registry.register(USER_DATA_LOADER, DataLoader.newMappedDataLoader(userDataLoader));
        return registry;
    }
}
  • DataLoaderRegistry는 요청 단위로 DataLoader를 보관하는 저장소이다.
  • GraphQL 실행 시 이 Registry에 등록된 모든 DataLoader가 함께 사용된다.

 

3️⃣ 요청 컨텍스트에 연결

@Component
public class GraphQLContextBuilderImpl implements GraphQLServletContextBuilder {

    @Autowired
    private DataLoaderRegistryFactory registryFactory;

    @Override
    public GraphQLContext build(HttpServletRequest request, HttpServletResponse response) {
        return DefaultGraphQLServletContext.createServletContext()
                .withDataLoaderRegistry(registryFactory.create())
                .build();
    }

    // WebSocket 지원 시 build(Session)도 구현 필요
}
  • 요청마다 새로운 DataLoaderRegistry를 생성하여 GraphQL 컨텍스트에 주입한다.
  • 이를 통해 DataLoader는 요청 범위 스코프를 유지한다.

 

4️⃣ 리졸버에서 DataLoader 사용

@Component
public class PostResolver implements GraphQLResolver<Post> {

    public CompletableFuture<User> getAuthor(Post post, DataFetchingEnvironment env) {
        DataLoader<Long, User> userLoader = env.getDataLoader(DataLoaderRegistryFactory.USER_DATA_LOADER);
        return userLoader.load(post.getAuthorId());
    }
}
  • env.getDataLoader()로 등록된 DataLoader 인스턴스를 가져온다.
  • 이후 작성자 ID를 전달하면, 내부적으로 batch 처리되어 효율적인 조회가 가능하다.
  • 게시글 10개를 요청하더라도 작성자 조회는 단 1번의 DB 호출로 해결 가능하다.

 

DataLoader 사용 시 주의사항

항목 설명
요청 범위 요청마다 새로운 DataLoaderRegistry 인스턴스를 생성해야 함
캐싱 동일한 키로 여러 번 호출해도 재요청되지 않음
순서 보장 MappedBatchLoader는 요청 순서에 맞게 결과를 매핑함
비동기 처리 리졸버에서 CompltableFuter<T>를 반환해야 정상 동작함
전역 사용 금지 DataLoader는 요청 스코프이므로 전역 빈으로 재사용하면 안 됨