Skip to content

Split View: GraphQL 완전 가이드 2025: REST를 넘어서 — 스키마 설계부터 Federation, 성능 최적화까지

✨ Learn with Quiz
|

GraphQL 완전 가이드 2025: REST를 넘어서 — 스키마 설계부터 Federation, 성능 최적화까지

들어가며: 왜 GraphQL인가?

2015년 Facebook이 오픈소스로 공개한 GraphQL은 이제 API 설계의 표준 선택지가 되었습니다. GitHub, Shopify, Netflix, Airbnb 등 주요 테크 기업들이 채택하며, REST의 한계를 넘어서는 새로운 패러다임을 보여주고 있습니다.

이 글에서는 GraphQL의 기초부터 Federation을 활용한 마이크로서비스 통합, 성능 최적화까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.


1. REST vs GraphQL: 근본적인 차이

REST의 한계

REST API는 오랫동안 웹 API의 표준이었지만, 몇 가지 고질적인 문제가 있습니다.

Over-fetching: 필요하지 않은 데이터까지 응답에 포함됩니다. 사용자 이름만 필요한데 전체 프로필 정보가 옵니다.

Under-fetching: 하나의 화면을 그리기 위해 여러 엔드포인트를 호출해야 합니다. 사용자 정보 + 게시글 + 댓글 = 3번의 요청.

엔드포인트 관리: 클라이언트 요구사항이 변할 때마다 새 엔드포인트를 만들거나 기존 것을 수정해야 합니다.

GraphQL의 해결책

# 한 번의 요청으로 필요한 데이터만 정확히 가져옵니다
query GetUserWithPosts {
  user(id: "123") {
    name
    email
    posts(first: 5) {
      title
      commentCount
    }
  }
}
구분RESTGraphQL
엔드포인트리소스마다 별도 URL단일 엔드포인트
데이터 양서버가 결정클라이언트가 결정
버전 관리v1, v2 URL 분기스키마 진화(deprecation)
타입 시스템Swagger/OpenAPI로 보강내장 타입 시스템(SDL)
실시간별도 WebSocket 구현Subscription 내장
캐싱HTTP 캐시 활용 용이별도 전략 필요
학습 곡선낮음중간~높음

핵심 차이: 선언적 데이터 요청

REST는 어디서(WHERE) 데이터를 가져올지 지정하고, GraphQL은 무엇을(WHAT) 가져올지 선언합니다.


2. 스키마 설계 (Schema Definition Language)

기본 타입 시스템

GraphQL의 스키마는 API의 계약서입니다. SDL을 사용하여 타입을 정의합니다.

# 스칼라 타입: String, Int, Float, Boolean, ID
# ! 는 Non-null을 의미합니다

type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  tags: [String!]
  publishedAt: DateTime
}

type Comment {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

Query, Mutation, Subscription

GraphQL에는 세 가지 작업 타입이 있습니다.

type Query {
  # 데이터 조회
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection!
  post(id: ID!): Post
  searchPosts(query: String!): [Post!]!
}

type Mutation {
  # 데이터 변경
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  createPost(input: CreatePostInput!): Post!
  likePost(postId: ID!): Post!
}

type Subscription {
  # 실시간 업데이트
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged(userId: ID!): User!
}

Input 타입과 Enum

input CreateUserInput {
  name: String!
  email: String!
  age: Int
  role: UserRole = USER
}

input UserFilter {
  role: UserRole
  nameContains: String
  createdAfter: DateTime
}

input PaginationInput {
  first: Int = 20
  after: String
}

enum UserRole {
  ADMIN
  EDITOR
  USER
  GUEST
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

Relay 스타일 커넥션 (Cursor-based Pagination)

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

3. Resolver 패턴

기본 Resolver 구조

Resolver는 스키마의 각 필드를 실제 데이터와 연결합니다.

// resolver의 4가지 인자: parent, args, context, info
const resolvers = {
  Query: {
    user: async (parent, args, context, info) => {
      const { id } = args;
      const { dataSources, currentUser } = context;
      return dataSources.userAPI.getUser(id);
    },

    users: async (_, { filter, pagination }, { dataSources }) => {
      return dataSources.userAPI.getUsers(filter, pagination);
    },

    searchPosts: async (_, { query }, { dataSources }) => {
      return dataSources.postAPI.search(query);
    },
  },

  Mutation: {
    createUser: async (_, { input }, { dataSources, currentUser }) => {
      if (!currentUser?.isAdmin) {
        throw new ForbiddenError('Admin access required');
      }
      return dataSources.userAPI.createUser(input);
    },

    likePost: async (_, { postId }, { dataSources, currentUser }) => {
      if (!currentUser) {
        throw new AuthenticationError('Login required');
      }
      return dataSources.postAPI.likePost(postId, currentUser.id);
    },
  },

  // 타입 resolver - 관계 필드 해결
  User: {
    posts: async (user, _, { dataSources }) => {
      return dataSources.postAPI.getPostsByAuthor(user.id);
    },
  },

  Post: {
    author: async (post, _, { dataSources }) => {
      return dataSources.userAPI.getUser(post.authorId);
    },
    comments: async (post, _, { dataSources }) => {
      return dataSources.commentAPI.getByPost(post.id);
    },
  },
};

Context 설정

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

await server.start();

app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => {
      const token = req.headers.authorization || '';
      const currentUser = await getUserFromToken(token);
      return {
        currentUser,
        dataSources: {
          userAPI: new UserDataSource(),
          postAPI: new PostDataSource(),
          commentAPI: new CommentDataSource(),
        },
      };
    },
  })
);

4. N+1 문제와 DataLoader

N+1 문제란?

게시글 목록을 가져온 뒤, 각 게시글의 작성자를 개별 쿼리로 가져오면 N+1 문제가 발생합니다.

Query: posts(first: 10)1쿼리 (게시글 10)
  Post.author (post 1)2번 쿼리
  Post.author (post 2)3번 쿼리
  ...
  Post.author (post 10)11번 쿼리

총 11번의 DB 쿼리가 실행됩니다.

DataLoader로 해결

DataLoader는 같은 이벤트 루프(tick) 내의 요청을 모아 배치 처리합니다.

import DataLoader from 'dataloader';

// 배치 함수: ID 배열을 받아 같은 순서로 결과 반환
const userLoader = new DataLoader(async (userIds: string[]) => {
  const users = await db.users.findMany({
    where: { id: { in: userIds } },
  });

  // 요청된 ID 순서대로 결과를 매핑
  const userMap = new Map(users.map((u) => [u.id, u]));
  return userIds.map((id) => userMap.get(id) || null);
});

// Resolver에서 사용
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => {
      return loaders.userLoader.load(post.authorId);
    },
  },
};

결과: 11번의 쿼리가 2번으로 줄어듭니다.

Query: posts(first: 10)1번 쿼리
  DataLoader batch: users([1,2,3,...,10])2쿼리 (배치)

DataLoader 컨텍스트 패턴

// 요청마다 새로운 DataLoader 인스턴스를 생성합니다
function createLoaders() {
  return {
    userLoader: new DataLoader((ids) => batchGetUsers(ids)),
    postLoader: new DataLoader((ids) => batchGetPosts(ids)),
    commentLoader: new DataLoader((ids) => batchGetComments(ids)),
    // 1:N 관계용 loader
    postsByAuthorLoader: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: authorIds } },
      });
      const grouped = groupBy(posts, 'authorId');
      return authorIds.map((id) => grouped[id] || []);
    }),
  };
}

// context에서 매 요청마다 새로 생성
context: async ({ req }) => ({
  currentUser: await getUser(req),
  loaders: createLoaders(),
}),

5. Apollo Server와 Client

Apollo Server 설정

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

const server = new ApolloServer({
  schema,
  plugins: [
    // 인트로스펙션 제한 (프로덕션)
    ApolloServerPluginLandingPageDisabled(),
    // 사용량 리포팅
    ApolloServerPluginUsageReporting(),
  ],
  formatError: (formattedError, error) => {
    // 에러 포맷팅 - 내부 정보 숨기기
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return { message: 'Internal server error' };
    }
    return formattedError;
  },
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    currentUser: await authenticateUser(req),
    loaders: createLoaders(),
  }),
});

console.log(`Server ready at ${url}`);

Apollo Client (React)

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
  useMutation,
  gql,
} from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            // Relay 스타일 커서 페이지네이션 머지
            keyArgs: ['filter'],
            merge(existing, incoming, { args }) {
              if (!args?.after) return incoming;
              return {
                ...incoming,
                edges: [...(existing?.edges || []), ...incoming.edges],
              };
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});

// React 컴포넌트에서 사용
const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          author {
            name
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostList() {
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10 },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error occurred</p>;

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      {data.posts.pageInfo.hasNextPage && (
        <button
          onClick={() =>
            fetchMore({
              variables: { after: data.posts.pageInfo.endCursor },
            })
          }
        >
          Load More
        </button>
      )}
    </div>
  );
}

6. Apollo Federation v2

Federation이란?

Apollo Federation은 여러 개의 GraphQL 서비스(Subgraph)를 하나의 통합 API(Supergraph)로 합칩니다. 각 팀이 독립적으로 서비스를 개발하면서도 클라이언트에게는 하나의 GraphQL 엔드포인트를 제공합니다.

Subgraph 설계

# --- Users Subgraph ---
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
}

type Query {
  user(id: ID!): User
  me: User
}

# --- Posts Subgraph ---
type Post @key(fields: "id") {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishedAt: DateTime
}

# 다른 subgraph의 User 타입 확장
type User @key(fields: "id") {
  id: ID!
  posts: [Post!]!        # Posts 서비스에서 추가하는 필드
  postCount: Int!
}

type Query {
  post(id: ID!): Post
  feed(first: Int = 20, after: String): PostConnection!
}

# --- Reviews Subgraph ---
type Review @key(fields: "id") {
  id: ID!
  rating: Int!
  comment: String
  author: User!
  post: Post!
}

type Post @key(fields: "id") {
  id: ID!
  reviews: [Review!]!     # Reviews 서비스에서 추가
  averageRating: Float
}

Router 설정 (Apollo Router)

# router.yaml
supergraph:
  listen: 0.0.0.0:4000

subgraphs:
  users:
    routing_url: http://users-service:4001/graphql
  posts:
    routing_url: http://posts-service:4002/graphql
  reviews:
    routing_url: http://reviews-service:4003/graphql

headers:
  all:
    request:
      - propagate:
          named: authorization

Subgraph의 Reference Resolver

// Posts Subgraph
const resolvers = {
  User: {
    // Federation이 User를 resolve할 때 호출
    __resolveReference: async (user, { loaders }) => {
      // user.id만 전달받음
      return loaders.postsByUserLoader.load(user.id);
    },
    posts: async (user, _, { dataSources }) => {
      return dataSources.postAPI.getByAuthor(user.id);
    },
    postCount: async (user, _, { dataSources }) => {
      return dataSources.postAPI.countByAuthor(user.id);
    },
  },
};

7. Subscription (실시간 기능)

서버 설정

import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const httpServer = createServer(app);
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authorization;
      const user = await authenticateUser(token);
      return { currentUser: user, pubsub };
    },
  },
  wsServer
);

// Resolver
const resolvers = {
  Subscription: {
    commentAdded: {
      subscribe: (_, { postId }) => {
        return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
      },
    },
    postCreated: {
      subscribe: () => pubsub.asyncIterator('POST_CREATED'),
    },
  },

  Mutation: {
    createComment: async (_, { input }, { pubsub, currentUser }) => {
      const comment = await db.comments.create({
        data: { ...input, authorId: currentUser.id },
      });

      // 구독자에게 알림
      pubsub.publish(`COMMENT_ADDED_${input.postId}`, {
        commentAdded: comment,
      });

      return comment;
    },
  },
};

클라이언트 Subscription

import { useSubscription, gql } from '@apollo/client';

const COMMENT_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postId: ID!) {
    commentAdded(postId: $postId) {
      id
      body
      author {
        name
      }
      createdAt
    }
  }
`;

function CommentFeed({ postId }) {
  const { data, loading } = useSubscription(COMMENT_SUBSCRIPTION, {
    variables: { postId },
  });

  // 새 댓글이 올 때마다 자동으로 data가 업데이트됩니다
  if (data?.commentAdded) {
    return <NewComment comment={data.commentAdded} />;
  }

  return null;
}

8. 인증(Authentication)과 인가(Authorization)

Directive 기반 인가

# 커스텀 directive 정의
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT

enum Role {
  ADMIN
  EDITOR
  USER
  GUEST
}

type Query {
  publicPosts: [Post!]!
  me: User! @auth
  adminDashboard: Dashboard! @auth(requires: ADMIN)
  userManagement: [User!]! @auth(requires: ADMIN)
}

type Mutation {
  createPost(input: CreatePostInput!): Post! @auth
  deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
  updateProfile(input: UpdateProfileInput!): User! @auth
}

Directive 구현

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';

function authDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];

      if (authDirective) {
        const requiredRole = authDirective.requires || 'USER';
        const originalResolve = fieldConfig.resolve || defaultFieldResolver;

        fieldConfig.resolve = async (source, args, context, info) => {
          const { currentUser } = context;

          if (!currentUser) {
            throw new AuthenticationError('Not authenticated');
          }

          if (!hasRole(currentUser, requiredRole)) {
            throw new ForbiddenError('Insufficient permissions');
          }

          return originalResolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
}

9. 성능 최적화

Query Complexity 제한

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  schema,
  validationRules: [
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
      formatErrorMessage: (cost) =>
        `Query too complex: cost ${cost} exceeds maximum 1000`,
    }),
  ],
});

Depth Limiting

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)],
});

Persisted Queries (APQ)

// 서버
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';

const server = new ApolloServer({
  schema,
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 60 })],
  persistedQueries: {
    cache: new KeyValueCache(), // Redis 등
  },
});

// 클라이언트
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const link = createPersistedQueryLink({ sha256 });

const client = new ApolloClient({
  link: link.concat(httpLink),
  cache: new InMemoryCache(),
});

캐시 제어

type Query {
  posts: [Post!]! @cacheControl(maxAge: 300)
  user(id: ID!): User @cacheControl(maxAge: 60)
  me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}

type Post @cacheControl(maxAge: 600) {
  id: ID!
  title: String!
  author: User! @cacheControl(inheritMaxAge: true)
  viewCount: Int! @cacheControl(maxAge: 30)
}

10. 캐싱 전략

서버 사이드 캐싱

import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';

const server = new ApolloServer({
  schema,
  cache: new KeyvAdapter(new Keyv({ store: new KeyvRedis('redis://localhost:6379') })),
});

// DataSource 레벨 캐싱
class CachedUserAPI {
  private cache: RedisClient;
  private ttl = 300; // 5분

  async getUser(id: string): Promise<User> {
    const cacheKey = `user:${id}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return JSON.parse(cached);

    const user = await db.users.findUnique({ where: { id } });
    await this.cache.set(cacheKey, JSON.stringify(user), 'EX', this.ttl);
    return user;
  }
}

클라이언트 사이드 캐싱 (Apollo Cache)

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      keyFields: ['id'],
      fields: {
        fullName: {
          read(_, { readField }) {
            const first = readField('firstName');
            const last = readField('lastName');
            return `${first} ${last}`;
          },
        },
      },
    },
    Post: {
      keyFields: ['id'],
    },
    Query: {
      fields: {
        post(_, { args, toReference }) {
          return toReference({ __typename: 'Post', id: args?.id });
        },
      },
    },
  },
});

11. 테스팅

Resolver 단위 테스트

import { describe, it, expect, vi } from 'vitest';

describe('User Resolvers', () => {
  it('should return user by id', async () => {
    const mockUser = { id: '1', name: 'Test User', email: 'test@example.com' };
    const context = {
      dataSources: {
        userAPI: { getUser: vi.fn().mockResolvedValue(mockUser) },
      },
    };

    const result = await resolvers.Query.user(null, { id: '1' }, context, null);
    expect(result).toEqual(mockUser);
    expect(context.dataSources.userAPI.getUser).toHaveBeenCalledWith('1');
  });

  it('should throw if not admin for createUser', async () => {
    const context = {
      currentUser: { role: 'USER' },
      dataSources: { userAPI: { createUser: vi.fn() } },
    };

    await expect(
      resolvers.Mutation.createUser(null, { input: {} }, context, null)
    ).rejects.toThrow('Admin access required');
  });
});

통합 테스트

import { ApolloServer } from '@apollo/server';

describe('GraphQL Integration', () => {
  let server: ApolloServer;

  beforeAll(async () => {
    server = new ApolloServer({ typeDefs, resolvers });
  });

  it('should execute GetUser query', async () => {
    const response = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            email
          }
        }
      `,
      variables: { id: '1' },
    });

    expect(response.body.kind).toBe('single');
    expect(response.body.singleResult.errors).toBeUndefined();
    expect(response.body.singleResult.data?.user).toBeDefined();
  });
});

12. GraphQL을 쓰지 말아야 할 때

모든 상황에 GraphQL이 적합하지는 않습니다.

REST가 나은 경우:

  • 단순 CRUD API (리소스 구조가 명확할 때)
  • 파일 업로드/다운로드가 주 기능인 서비스
  • HTTP 캐싱이 핵심인 공개 API
  • 팀 규모가 작고 러닝 커브를 감당하기 어려울 때
  • 서버 간 내부 통신 (gRPC가 더 적합)

GraphQL이 빛나는 경우:

  • 다양한 클라이언트(웹, 모바일, TV)가 같은 API를 사용
  • 프론트엔드 팀이 빠르게 이터레이션해야 할 때
  • 데이터 관계가 복잡한 도메인 (SNS, 이커머스)
  • 마이크로서비스를 통합 API로 제공해야 할 때
  • 실시간 기능이 필요한 애플리케이션

13. 실무 면접 질문 15선

기초 (1-5)

Q1. GraphQL과 REST의 핵심 차이를 설명하세요.

GraphQL은 클라이언트가 필요한 데이터의 구조를 직접 명시하는 쿼리 언어입니다. REST는 서버가 정의한 엔드포인트별로 고정된 응답을 반환하는 반면, GraphQL은 단일 엔드포인트에서 클라이언트가 원하는 필드만 선택적으로 요청합니다.

Q2. Over-fetching과 Under-fetching을 설명하세요.

Over-fetching은 API가 클라이언트에게 필요한 것 이상의 데이터를 반환하는 것이고, Under-fetching은 필요한 데이터를 얻기 위해 여러 번 API를 호출해야 하는 것입니다.

Q3. SDL(Schema Definition Language)의 주요 타입들을 설명하세요.

Object 타입(커스텀 필드 정의), Scalar 타입(String, Int, Float, Boolean, ID), Enum(열거형), Input(입력용 객체), Interface(공통 필드 정의), Union(여러 타입 중 하나)이 있습니다.

Q4. Query, Mutation, Subscription의 차이를 설명하세요.

Query는 데이터 조회(GET과 유사), Mutation은 데이터 변경(POST/PUT/DELETE와 유사), Subscription은 WebSocket을 통한 실시간 이벤트 스트림입니다.

Q5. Non-null(!)과 리스트 타입의 조합을 설명하세요.

[String]은 null 가능한 문자열의 null 가능한 배열, [String!]은 null 불가 문자열의 null 가능한 배열, [String!]!은 null 불가 문자열의 null 불가 배열입니다.

중급 (6-10)

Q6. N+1 문제가 무엇이며 어떻게 해결하나요?

부모 목록을 조회한 뒤 각 항목의 관계 데이터를 개별 쿼리로 가져오는 문제입니다. DataLoader로 같은 틱의 요청을 모아 한 번의 배치 쿼리로 실행합니다.

Q7. Apollo Federation의 핵심 개념을 설명하세요.

여러 Subgraph를 Router가 하나의 Supergraph로 합칩니다. @key로 엔티티를 식별하고, __resolveReference로 다른 서비스의 엔티티를 resolve합니다.

Q8. GraphQL에서 인증과 인가를 어떻게 구현하나요?

인증은 context에서 토큰을 검증하여 currentUser를 설정합니다. 인가는 커스텀 directive(@auth)나 resolver 내부에서 역할 기반 검사를 수행합니다.

Q9. Persisted Queries의 장점은 무엇인가요?

쿼리 문자열 대신 해시값을 전송하여 네트워크 페이로드를 줄이고, 서버에서 허용된 쿼리만 실행하여 보안을 강화합니다.

Q10. Cursor-based vs Offset-based Pagination의 차이는?

Offset은 페이지 번호 기반으로 중간 삽입/삭제 시 데이터가 중복/누락됩니다. Cursor는 특정 위치(opaque cursor) 기반으로 실시간 데이터에서도 안정적입니다.

고급 (11-15)

Q11. Query Complexity와 Depth Limit을 설명하세요.

Query Complexity는 각 필드에 비용을 부여해 총 비용이 한도를 초과하면 거부합니다. Depth Limit은 중첩 깊이를 제한하여 악의적인 재귀 쿼리를 방지합니다.

Q12. Federation에서 Entity와 Reference Resolver의 동작을 설명하세요.

Entity는 @key로 식별되는 타입입니다. Router가 다른 subgraph의 데이터가 필요할 때 __resolveReference를 호출하여 key 필드만으로 전체 엔티티를 resolve합니다.

Q13. GraphQL의 캐싱이 REST보다 어려운 이유는?

REST는 URL 기반 HTTP 캐시를 직접 활용하지만, GraphQL은 단일 엔드포인트에 POST 요청을 사용하므로 HTTP 캐시를 쓸 수 없습니다. APQ, CDN 캐시 키 설정, 정규화된 클라이언트 캐시 등 별도 전략이 필요합니다.

Q14. Subscription의 프로덕션 스케일링은 어떻게 하나요?

인메모리 PubSub 대신 Redis PubSub이나 Kafka를 사용합니다. 여러 서버 인스턴스 간 메시지를 공유하며, WebSocket 연결의 로드밸런싱에는 sticky session이나 별도 WebSocket 게이트웨이를 구성합니다.

Q15. 스키마 진화(Evolution) 전략을 설명하세요.

@deprecated directive로 필드를 폐기 예고하고, 새 필드를 추가합니다. 필드 제거 전 클라이언트 사용량을 모니터링하고, 스키마 레지스트리로 변경 호환성을 검증합니다. REST의 URL 버전 관리보다 유연합니다.


14. 실전 퀴즈 5문제

Q1. 다음 중 GraphQL Query에서 "반드시 null이 아닌 문자열 배열"을 나타내는 타입은?

정답: [String!]!

[String!]! — 배열 자체도 null이 아니고(!), 배열 내 각 요소도 null이 아닙니다(String!). [String]은 배열도 null 가능, 요소도 null 가능합니다.

Q2. DataLoader의 핵심 동작 원리를 설명하세요.

정답: 같은 이벤트 루프 틱 내의 .load() 호출을 모아 배치 함수를 한 번 실행합니다.

DataLoader는 요청을 큐에 쌓다가 현재 틱이 끝나면 모든 ID를 하나의 배치 함수에 전달합니다. 이를 통해 N+1 쿼리를 1+1 쿼리로 줄입니다.

Q3. Apollo Federation에서 @key directive의 역할은?

정답: 엔티티(Entity)를 고유하게 식별하는 필드를 지정합니다.

@key(fields: "id")는 해당 타입이 Federation의 엔티티임을 선언하고, Router가 다른 subgraph에서 이 엔티티를 참조할 때 id 필드를 사용하여 resolve할 수 있게 합니다.

Q4. GraphQL Subscription이 내부적으로 사용하는 프로토콜은?

정답: WebSocket

Subscription은 HTTP 요청-응답이 아닌 WebSocket을 통한 양방향 연결을 사용합니다. graphql-ws 프로토콜로 서버에서 클라이언트로 실시간 이벤트를 push합니다.

Q5. Persisted Queries의 보안상 이점을 설명하세요.

정답: 서버에 등록된 쿼리만 실행하여 임의의 쿼리(악의적 쿼리) 실행을 방지합니다.

APQ(Automatic Persisted Queries)는 쿼리의 SHA256 해시를 전송합니다. 서버는 해시와 매핑된 쿼리만 실행하므로 공격자가 임의의 복잡한 쿼리를 보내 서버 자원을 소모하는 것을 막습니다.


참고 자료 (References)

  1. GraphQL 공식 문서
  2. Apollo GraphQL 문서
  3. Apollo Federation 가이드
  4. GraphQL Spec (2021)
  5. DataLoader GitHub
  6. graphql-ws 프로토콜
  7. How to GraphQL - 튜토리얼
  8. Relay 공식 문서
  9. GraphQL Best Practices
  10. Production-Ready GraphQL (Marc-Andre Giroux)
  11. The Guild - GraphQL Tools
  12. GraphQL Security (OWASP)
  13. Apollo Client React 가이드
  14. Netflix: GraphQL at Scale
  15. Shopify: GraphQL Design Tutorial
  16. GitHub GraphQL API

GraphQL Complete Guide 2025: Beyond REST — From Schema Design to Federation and Performance Optimization

Introduction: Why GraphQL?

Since Facebook open-sourced GraphQL in 2015, it has become a standard choice for API design. Major tech companies including GitHub, Shopify, Netflix, and Airbnb have adopted it, demonstrating a new paradigm that transcends the limitations of REST.

This article covers everything from GraphQL basics to microservice integration with Federation and performance optimization — all practical knowledge you can apply immediately.


1. REST vs GraphQL: Fundamental Differences

REST Limitations

REST APIs have been the web API standard for years, but they suffer from several persistent issues.

Over-fetching: Responses include data you don't need. You only need a username but get the entire profile.

Under-fetching: You need multiple endpoint calls to render a single screen. User info + posts + comments = 3 requests.

Endpoint management: Every time client requirements change, you must create new endpoints or modify existing ones.

How GraphQL Solves These

# Get exactly the data you need in a single request
query GetUserWithPosts {
  user(id: "123") {
    name
    email
    posts(first: 5) {
      title
      commentCount
    }
  }
}
AspectRESTGraphQL
EndpointsSeparate URL per resourceSingle endpoint
Data amountServer decidesClient decides
Versioningv1, v2 URL branchingSchema evolution (deprecation)
Type systemAugmented with Swagger/OpenAPIBuilt-in type system (SDL)
Real-timeSeparate WebSocket implBuilt-in Subscriptions
CachingEasy HTTP cache utilizationRequires separate strategy
Learning curveLowMedium to high

Core Difference: Declarative Data Fetching

REST specifies WHERE to get data from, while GraphQL declares WHAT data to get.


2. Schema Design (Schema Definition Language)

Basic Type System

The GraphQL schema serves as the API contract. You define types using SDL.

# Scalar types: String, Int, Float, Boolean, ID
# ! means Non-null

type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  tags: [String!]
  publishedAt: DateTime
}

type Comment {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

Query, Mutation, Subscription

GraphQL has three operation types.

type Query {
  # Data retrieval
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection!
  post(id: ID!): Post
  searchPosts(query: String!): [Post!]!
}

type Mutation {
  # Data modification
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  createPost(input: CreatePostInput!): Post!
  likePost(postId: ID!): Post!
}

type Subscription {
  # Real-time updates
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged(userId: ID!): User!
}

Input Types and Enums

input CreateUserInput {
  name: String!
  email: String!
  age: Int
  role: UserRole = USER
}

input UserFilter {
  role: UserRole
  nameContains: String
  createdAfter: DateTime
}

input PaginationInput {
  first: Int = 20
  after: String
}

enum UserRole {
  ADMIN
  EDITOR
  USER
  GUEST
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

Relay-Style Connections (Cursor-based Pagination)

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

3. Resolver Patterns

Basic Resolver Structure

Resolvers connect each schema field to actual data.

// Resolver's 4 arguments: parent, args, context, info
const resolvers = {
  Query: {
    user: async (parent, args, context, info) => {
      const { id } = args;
      const { dataSources, currentUser } = context;
      return dataSources.userAPI.getUser(id);
    },

    users: async (_, { filter, pagination }, { dataSources }) => {
      return dataSources.userAPI.getUsers(filter, pagination);
    },

    searchPosts: async (_, { query }, { dataSources }) => {
      return dataSources.postAPI.search(query);
    },
  },

  Mutation: {
    createUser: async (_, { input }, { dataSources, currentUser }) => {
      if (!currentUser?.isAdmin) {
        throw new ForbiddenError('Admin access required');
      }
      return dataSources.userAPI.createUser(input);
    },

    likePost: async (_, { postId }, { dataSources, currentUser }) => {
      if (!currentUser) {
        throw new AuthenticationError('Login required');
      }
      return dataSources.postAPI.likePost(postId, currentUser.id);
    },
  },

  // Type resolver - resolving relationship fields
  User: {
    posts: async (user, _, { dataSources }) => {
      return dataSources.postAPI.getPostsByAuthor(user.id);
    },
  },

  Post: {
    author: async (post, _, { dataSources }) => {
      return dataSources.userAPI.getUser(post.authorId);
    },
    comments: async (post, _, { dataSources }) => {
      return dataSources.commentAPI.getByPost(post.id);
    },
  },
};

Context Setup

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

await server.start();

app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => {
      const token = req.headers.authorization || '';
      const currentUser = await getUserFromToken(token);
      return {
        currentUser,
        dataSources: {
          userAPI: new UserDataSource(),
          postAPI: new PostDataSource(),
          commentAPI: new CommentDataSource(),
        },
      };
    },
  })
);

4. The N+1 Problem and DataLoader

What Is the N+1 Problem?

When you fetch a list of posts and then fetch each post's author individually, the N+1 problem occurs.

Query: posts(first: 10)        -> 1 query (10 posts)
  Post.author (post 1)         -> query 2
  Post.author (post 2)         -> query 3
  ...
  Post.author (post 10)        -> query 11

That's 11 total database queries.

Solving with DataLoader

DataLoader batches requests within the same event loop tick.

import DataLoader from 'dataloader';

// Batch function: receives array of IDs, returns results in same order
const userLoader = new DataLoader(async (userIds: string[]) => {
  const users = await db.users.findMany({
    where: { id: { in: userIds } },
  });

  // Map results in the order of requested IDs
  const userMap = new Map(users.map((u) => [u.id, u]));
  return userIds.map((id) => userMap.get(id) || null);
});

// Usage in resolvers
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => {
      return loaders.userLoader.load(post.authorId);
    },
  },
};

Result: 11 queries reduced to 2.

Query: posts(first: 10)                     -> 1 query
  DataLoader batch: users([1,2,3,...,10])    -> 2 queries (batched)

DataLoader Context Pattern

// Create new DataLoader instances per request
function createLoaders() {
  return {
    userLoader: new DataLoader((ids) => batchGetUsers(ids)),
    postLoader: new DataLoader((ids) => batchGetPosts(ids)),
    commentLoader: new DataLoader((ids) => batchGetComments(ids)),
    // Loader for 1:N relationships
    postsByAuthorLoader: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: authorIds } },
      });
      const grouped = groupBy(posts, 'authorId');
      return authorIds.map((id) => grouped[id] || []);
    }),
  };
}

// Create fresh loaders per request in context
context: async ({ req }) => ({
  currentUser: await getUser(req),
  loaders: createLoaders(),
}),

5. Apollo Server and Client

Apollo Server Setup

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

const server = new ApolloServer({
  schema,
  plugins: [
    // Disable introspection in production
    ApolloServerPluginLandingPageDisabled(),
    // Usage reporting
    ApolloServerPluginUsageReporting(),
  ],
  formatError: (formattedError, error) => {
    // Error formatting - hide internal info
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return { message: 'Internal server error' };
    }
    return formattedError;
  },
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    currentUser: await authenticateUser(req),
    loaders: createLoaders(),
  }),
});

console.log(`Server ready at ${url}`);

Apollo Client (React)

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
  useMutation,
  gql,
} from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            // Relay-style cursor pagination merge
            keyArgs: ['filter'],
            merge(existing, incoming, { args }) {
              if (!args?.after) return incoming;
              return {
                ...incoming,
                edges: [...(existing?.edges || []), ...incoming.edges],
              };
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});

// Usage in React components
const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          author {
            name
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostList() {
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10 },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error occurred</p>;

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      {data.posts.pageInfo.hasNextPage && (
        <button
          onClick={() =>
            fetchMore({
              variables: { after: data.posts.pageInfo.endCursor },
            })
          }
        >
          Load More
        </button>
      )}
    </div>
  );
}

6. Apollo Federation v2

What Is Federation?

Apollo Federation combines multiple GraphQL services (Subgraphs) into a single unified API (Supergraph). Each team develops services independently while providing clients with a single GraphQL endpoint.

Subgraph Design

# --- Users Subgraph ---
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
}

type Query {
  user(id: ID!): User
  me: User
}

# --- Posts Subgraph ---
type Post @key(fields: "id") {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishedAt: DateTime
}

# Extending User type from another subgraph
type User @key(fields: "id") {
  id: ID!
  posts: [Post!]!        # Field added by Posts service
  postCount: Int!
}

type Query {
  post(id: ID!): Post
  feed(first: Int = 20, after: String): PostConnection!
}

# --- Reviews Subgraph ---
type Review @key(fields: "id") {
  id: ID!
  rating: Int!
  comment: String
  author: User!
  post: Post!
}

type Post @key(fields: "id") {
  id: ID!
  reviews: [Review!]!     # Added by Reviews service
  averageRating: Float
}

Router Configuration (Apollo Router)

# router.yaml
supergraph:
  listen: 0.0.0.0:4000

subgraphs:
  users:
    routing_url: http://users-service:4001/graphql
  posts:
    routing_url: http://posts-service:4002/graphql
  reviews:
    routing_url: http://reviews-service:4003/graphql

headers:
  all:
    request:
      - propagate:
          named: authorization

Subgraph Reference Resolver

// Posts Subgraph
const resolvers = {
  User: {
    // Called when Federation needs to resolve a User
    __resolveReference: async (user, { loaders }) => {
      // Only user.id is passed
      return loaders.postsByUserLoader.load(user.id);
    },
    posts: async (user, _, { dataSources }) => {
      return dataSources.postAPI.getByAuthor(user.id);
    },
    postCount: async (user, _, { dataSources }) => {
      return dataSources.postAPI.countByAuthor(user.id);
    },
  },
};

7. Subscriptions (Real-Time Features)

Server Setup

import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const httpServer = createServer(app);
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authorization;
      const user = await authenticateUser(token);
      return { currentUser: user, pubsub };
    },
  },
  wsServer
);

// Resolvers
const resolvers = {
  Subscription: {
    commentAdded: {
      subscribe: (_, { postId }) => {
        return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
      },
    },
    postCreated: {
      subscribe: () => pubsub.asyncIterator('POST_CREATED'),
    },
  },

  Mutation: {
    createComment: async (_, { input }, { pubsub, currentUser }) => {
      const comment = await db.comments.create({
        data: { ...input, authorId: currentUser.id },
      });

      // Notify subscribers
      pubsub.publish(`COMMENT_ADDED_${input.postId}`, {
        commentAdded: comment,
      });

      return comment;
    },
  },
};

Client Subscription

import { useSubscription, gql } from '@apollo/client';

const COMMENT_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postId: ID!) {
    commentAdded(postId: $postId) {
      id
      body
      author {
        name
      }
      createdAt
    }
  }
`;

function CommentFeed({ postId }) {
  const { data, loading } = useSubscription(COMMENT_SUBSCRIPTION, {
    variables: { postId },
  });

  // data updates automatically with each new comment
  if (data?.commentAdded) {
    return <NewComment comment={data.commentAdded} />;
  }

  return null;
}

8. Authentication and Authorization

Directive-Based Authorization

# Custom directive definition
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT

enum Role {
  ADMIN
  EDITOR
  USER
  GUEST
}

type Query {
  publicPosts: [Post!]!
  me: User! @auth
  adminDashboard: Dashboard! @auth(requires: ADMIN)
  userManagement: [User!]! @auth(requires: ADMIN)
}

type Mutation {
  createPost(input: CreatePostInput!): Post! @auth
  deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
  updateProfile(input: UpdateProfileInput!): User! @auth
}

Directive Implementation

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';

function authDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];

      if (authDirective) {
        const requiredRole = authDirective.requires || 'USER';
        const originalResolve = fieldConfig.resolve || defaultFieldResolver;

        fieldConfig.resolve = async (source, args, context, info) => {
          const { currentUser } = context;

          if (!currentUser) {
            throw new AuthenticationError('Not authenticated');
          }

          if (!hasRole(currentUser, requiredRole)) {
            throw new ForbiddenError('Insufficient permissions');
          }

          return originalResolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
}

9. Performance Optimization

Query Complexity Limiting

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  schema,
  validationRules: [
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
      formatErrorMessage: (cost) =>
        `Query too complex: cost ${cost} exceeds maximum 1000`,
    }),
  ],
});

Depth Limiting

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)],
});

Persisted Queries (APQ)

// Server
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';

const server = new ApolloServer({
  schema,
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 60 })],
  persistedQueries: {
    cache: new KeyValueCache(), // Redis, etc.
  },
});

// Client
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const link = createPersistedQueryLink({ sha256 });

const client = new ApolloClient({
  link: link.concat(httpLink),
  cache: new InMemoryCache(),
});

Cache Control

type Query {
  posts: [Post!]! @cacheControl(maxAge: 300)
  user(id: ID!): User @cacheControl(maxAge: 60)
  me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}

type Post @cacheControl(maxAge: 600) {
  id: ID!
  title: String!
  author: User! @cacheControl(inheritMaxAge: true)
  viewCount: Int! @cacheControl(maxAge: 30)
}

10. Caching Strategies

Server-Side Caching

import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';

const server = new ApolloServer({
  schema,
  cache: new KeyvAdapter(new Keyv({ store: new KeyvRedis('redis://localhost:6379') })),
});

// DataSource-level caching
class CachedUserAPI {
  private cache: RedisClient;
  private ttl = 300; // 5 minutes

  async getUser(id: string): Promise<User> {
    const cacheKey = `user:${id}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return JSON.parse(cached);

    const user = await db.users.findUnique({ where: { id } });
    await this.cache.set(cacheKey, JSON.stringify(user), 'EX', this.ttl);
    return user;
  }
}

Client-Side Caching (Apollo Cache)

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      keyFields: ['id'],
      fields: {
        fullName: {
          read(_, { readField }) {
            const first = readField('firstName');
            const last = readField('lastName');
            return `${first} ${last}`;
          },
        },
      },
    },
    Post: {
      keyFields: ['id'],
    },
    Query: {
      fields: {
        post(_, { args, toReference }) {
          return toReference({ __typename: 'Post', id: args?.id });
        },
      },
    },
  },
});

11. Testing

Resolver Unit Tests

import { describe, it, expect, vi } from 'vitest';

describe('User Resolvers', () => {
  it('should return user by id', async () => {
    const mockUser = { id: '1', name: 'Test User', email: 'test@example.com' };
    const context = {
      dataSources: {
        userAPI: { getUser: vi.fn().mockResolvedValue(mockUser) },
      },
    };

    const result = await resolvers.Query.user(null, { id: '1' }, context, null);
    expect(result).toEqual(mockUser);
    expect(context.dataSources.userAPI.getUser).toHaveBeenCalledWith('1');
  });

  it('should throw if not admin for createUser', async () => {
    const context = {
      currentUser: { role: 'USER' },
      dataSources: { userAPI: { createUser: vi.fn() } },
    };

    await expect(
      resolvers.Mutation.createUser(null, { input: {} }, context, null)
    ).rejects.toThrow('Admin access required');
  });
});

Integration Tests

import { ApolloServer } from '@apollo/server';

describe('GraphQL Integration', () => {
  let server: ApolloServer;

  beforeAll(async () => {
    server = new ApolloServer({ typeDefs, resolvers });
  });

  it('should execute GetUser query', async () => {
    const response = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            email
          }
        }
      `,
      variables: { id: '1' },
    });

    expect(response.body.kind).toBe('single');
    expect(response.body.singleResult.errors).toBeUndefined();
    expect(response.body.singleResult.data?.user).toBeDefined();
  });
});

12. When NOT to Use GraphQL

GraphQL is not the right fit for every situation.

REST is better when:

  • Simple CRUD APIs with clear resource structures
  • File upload/download-centric services
  • Public APIs where HTTP caching is critical
  • Small teams that cannot absorb the learning curve
  • Server-to-server internal communication (gRPC is more suitable)

GraphQL shines when:

  • Multiple clients (web, mobile, TV) share the same API
  • Frontend teams need fast iteration
  • Complex data relationships (social networks, e-commerce)
  • Unifying microservices under a single API
  • Applications requiring real-time features

13. Interview Questions: 15 Essential Topics

Basics (1-5)

Q1. Explain the core differences between GraphQL and REST.

GraphQL is a query language where clients specify the exact structure of data they need. While REST returns fixed responses from server-defined endpoints, GraphQL lets clients selectively request only desired fields from a single endpoint.

Q2. Explain over-fetching and under-fetching.

Over-fetching occurs when an API returns more data than the client needs. Under-fetching occurs when the client must make multiple API calls to get all the data required for a single view.

Q3. Describe the main types in SDL (Schema Definition Language).

Object types (custom field definitions), Scalar types (String, Int, Float, Boolean, ID), Enums (enumerated values), Input (input objects), Interface (common field definitions), and Union (one of several types).

Q4. Explain the differences between Query, Mutation, and Subscription.

Query is for data retrieval (similar to GET), Mutation is for data modification (similar to POST/PUT/DELETE), and Subscription is for real-time event streams via WebSocket.

Q5. Explain Non-null (!) and list type combinations.

[String] is a nullable array of nullable strings, [String!] is a nullable array of non-null strings, [String!]! is a non-null array of non-null strings.

Intermediate (6-10)

Q6. What is the N+1 problem and how do you solve it?

When fetching a parent list and then individually querying each item's related data. DataLoader collects requests within the same tick and executes them as a single batch query.

Q7. Explain the core concepts of Apollo Federation.

Multiple Subgraphs are combined by a Router into a single Supergraph. @key identifies entities, and __resolveReference resolves entities from other services.

Q8. How do you implement authentication and authorization in GraphQL?

Authentication verifies tokens in context to set currentUser. Authorization uses custom directives (@auth) or role-based checks within resolvers.

Q9. What are the advantages of Persisted Queries?

They reduce network payload by sending query hashes instead of strings, and enhance security by only executing server-registered queries.

Q10. What's the difference between Cursor-based and Offset-based Pagination?

Offset pagination uses page numbers and can cause data duplication/loss during insertions/deletions. Cursor pagination uses opaque cursors based on specific positions and remains stable even with real-time data changes.

Advanced (11-15)

Q11. Explain Query Complexity and Depth Limiting.

Query Complexity assigns costs to fields and rejects queries exceeding the total cost threshold. Depth Limiting restricts nesting depth to prevent malicious recursive queries.

Q12. Explain Entity and Reference Resolver behavior in Federation.

An Entity is a type identified by @key. When the Router needs data from another subgraph, it calls __resolveReference to resolve the full entity using only key fields.

Q13. Why is GraphQL caching harder than REST?

REST directly leverages URL-based HTTP caching, but GraphQL uses a single endpoint with POST requests so HTTP caching cannot be used. Separate strategies like APQ, CDN cache key configuration, and normalized client caches are needed.

Q14. How do you scale Subscriptions in production?

Use Redis PubSub or Kafka instead of in-memory PubSub. Share messages across server instances, and configure sticky sessions or a dedicated WebSocket gateway for load balancing WebSocket connections.

Q15. Explain schema evolution strategy.

Use @deprecated directive to mark fields for deprecation and add new fields. Monitor client usage before removing fields, and verify change compatibility with a schema registry. This is more flexible than REST URL versioning.


14. Practice Quiz: 5 Questions

Q1. Which type represents "a non-null array of non-null strings" in GraphQL?

Answer: [String!]!

[String!]! means both the array itself is non-null (!) and each element is non-null (String!). [String] means both the array and elements can be null.

Q2. Explain the core operating principle of DataLoader.

Answer: It collects .load() calls within the same event loop tick and executes the batch function once.

DataLoader queues requests and when the current tick ends, passes all IDs to a single batch function. This reduces N+1 queries to 1+1 queries.

Q3. What is the role of the @key directive in Apollo Federation?

Answer: It specifies the fields that uniquely identify an Entity.

@key(fields: "id") declares the type as a Federation entity and enables the Router to resolve this entity from other subgraphs using the id field.

Q4. What protocol does GraphQL Subscription use internally?

Answer: WebSocket

Subscriptions use bidirectional connections via WebSocket rather than HTTP request-response. The graphql-ws protocol pushes real-time events from server to client.

Q5. Explain the security benefit of Persisted Queries.

Answer: Only server-registered queries can be executed, preventing arbitrary (malicious) query execution.

APQ (Automatic Persisted Queries) sends SHA256 hashes of queries. The server only executes queries mapped to known hashes, preventing attackers from sending arbitrary complex queries to consume server resources.


References

  1. GraphQL Official Documentation
  2. Apollo GraphQL Documentation
  3. Apollo Federation Guide
  4. GraphQL Spec (2021)
  5. DataLoader GitHub
  6. graphql-ws Protocol
  7. How to GraphQL - Tutorial
  8. Relay Official Documentation
  9. GraphQL Best Practices
  10. Production-Ready GraphQL (Marc-Andre Giroux)
  11. The Guild - GraphQL Tools
  12. GraphQL Security (OWASP)
  13. Apollo Client React Guide
  14. Netflix: GraphQL at Scale
  15. Shopify: GraphQL Design Tutorial
  16. GitHub GraphQL API