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

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 왜 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
}
}
}
| 구분 | REST | GraphQL |
|---|---|---|
| 엔드포인트 | 리소스마다 별도 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)
- GraphQL 공식 문서
- Apollo GraphQL 문서
- Apollo Federation 가이드
- GraphQL Spec (2021)
- DataLoader GitHub
- graphql-ws 프로토콜
- How to GraphQL - 튜토리얼
- Relay 공식 문서
- GraphQL Best Practices
- Production-Ready GraphQL (Marc-Andre Giroux)
- The Guild - GraphQL Tools
- GraphQL Security (OWASP)
- Apollo Client React 가이드
- Netflix: GraphQL at Scale
- Shopify: GraphQL Design Tutorial
- GitHub GraphQL API