GraphQL API 설계와 최적화 완벽 가이드
소개
GraphQL은 클라이언트가 필요한 필드만 요청할 수 있어, 오버페칭·언더페칭이 잦은 REST보다 프론트와 맞추기 좋습니다. 다만 N+1·과도한 중첩은 성능을 쉽게 망가뜨립니다. 우리가 내부/파트너 API를 GraphQL로 설계하면서 겪은 트레이드오프와 최적화 방법을 실전 관점에서 정리했습니다.
GraphQL 기본 개념
스키마 정의
typescript
1import { buildSchema } from 'graphql';
2
3const schema = buildSchema(`
4 type User {
5 id: ID!
6 name: String!
7 email: String!
8 posts: [Post!]!
9 createdAt: DateTime!
10 }
11
12 type Post {
13 id: ID!
14 title: String!
15 content: String!
16 author: User!
17 comments: [Comment!]!
18 createdAt: DateTime!
19 }
20
21 type Comment {
22 id: ID!
23 content: String!
24 author: User!
25 post: Post!
26 createdAt: DateTime!
27 }
28
29 type Query {
30 user(id: ID!): User
31 users(limit: Int, offset: Int): [User!]!
32 post(id: ID!): Post
33 posts(limit: Int, offset: Int): [Post!]!
34 }
35
36 type Mutation {
37 createUser(input: CreateUserInput!): User!
38 updateUser(id: ID!, input: UpdateUserInput!): User!
39 deleteUser(id: ID!): Boolean!
40 createPost(input: CreatePostInput!): Post!
41 }
42
43 input CreateUserInput {
44 name: String!
45 email: String!
46 password: String!
47 }
48
49 input UpdateUserInput {
50 name: String
51 email: String
52 }
53
54 input CreatePostInput {
55 title: String!
56 content: String!
57 authorId: ID!
58 }
59
60 scalar DateTime
61`);TypeScript와 GraphQL Code Generator
typescript
1// codegen.yml
2schema: http://localhost:4000/graphql
3documents: './src/**/*.graphql'
4generates:
5 ./src/generated/graphql.ts:
6 plugins:
7 - typescript
8 - typescript-operations
9 - typescript-react-apollographql
1# src/queries/getUser.graphql
2query GetUser($id: ID!) {
3 user(id: $id) {
4 id
5 name
6 email
7 posts {
8 id
9 title
10 createdAt
11 }
12 }
13}리졸버 구현
기본 리졸버
typescript
1import { GraphQLResolveInfo } from 'graphql';
2import { User, Post } from './models';
3
4const resolvers = {
5 Query: {
6 user: async (
7 parent: any,
8 args: { id: string },
9 context: any,
10 info: GraphQLResolveInfo
11 ) => {
12 return await User.findById(args.id);
13 },
14
15 users: async (
16 parent: any,
17 args: { limit?: number; offset?: number },
18 context: any
19 ) => {
20 const limit = args.limit || 10;
21 const offset = args.offset || 0;
22 return await User.find()
23 .limit(limit)
24 .skip(offset);
25 }
26 },
27
28 User: {
29 posts: async (parent: User) => {
30 return await Post.find({ authorId: parent.id });
31 }
32 },
33
34 Post: {
35 author: async (parent: Post) => {
36 return await User.findById(parent.authorId);
37 },
38
39 comments: async (parent: Post) => {
40 return await Comment.find({ postId: parent.id });
41 }
42 }
43};N+1 문제 해결
DataLoader를 사용한 배칭
typescript
1import DataLoader from 'dataloader';
2import { User, Post } from './models';
3
4// User DataLoader
5const userLoader = new DataLoader<string, User>(async (userIds) => {
6 const users = await User.find({ _id: { $in: userIds } });
7 const userMap = new Map(users.map(user => [user.id, user]));
8 return userIds.map(id => userMap.get(id) || null);
9});
10
11// Post DataLoader
12const postLoader = new DataLoader<string, Post[]>(async (authorIds) => {
13 const posts = await Post.find({ authorId: { $in: authorIds } });
14 const postsByAuthor = new Map<string, Post[]>();
15
16 for (const post of posts) {
17 const authorId = post.authorId.toString();
18 if (!postsByAuthor.has(authorId)) {
19 postsByAuthor.set(authorId, []);
20 }
21 postsByAuthor.get(authorId)!.push(post);
22 }
23
24 return authorIds.map(id => postsByAuthor.get(id.toString()) || []);
25});
26
27// 리졸버에서 사용
28const resolvers = {
29 Post: {
30 author: async (parent: Post) => {
31 return await userLoader.load(parent.authorId);
32 }
33 },
34
35 User: {
36 posts: async (parent: User) => {
37 return await postLoader.load(parent.id);
38 }
39 }
40};커스텀 DataLoader
typescript
1class CustomDataLoader<K, V> extends DataLoader<K, V> {
2 constructor(
3 batchLoadFn: DataLoader.BatchLoadFn<K, V>,
4 options?: DataLoader.Options<K, V>
5 ) {
6 super(batchLoadFn, {
7 ...options,
8 cache: true,
9 maxBatchSize: 100
10 });
11 }
12
13 // 캐시 무효화
14 clear(key: K): this {
15 return super.clear(key);
16 }
17
18 // 전체 캐시 클리어
19 clearAll(): this {
20 this.clearAll();
21 return this;
22 }
23}쿼리 복잡도 제한
typescript
1import { graphql } from 'graphql';
2import depthLimit from 'graphql-depth-limit';
3import { createComplexityLimitRule } from 'graphql-query-complexity';
4
5const complexityLimit = createComplexityLimitRule(1000, {
6 scalarCost: 1,
7 objectCost: 10,
8 listFactor: 10,
9 introspectionQueries: false
10});
11
12const validationRules = [
13 depthLimit(10),
14 complexityLimit
15];
16
17app.use('/graphql', graphqlHTTP({
18 schema,
19 rootValue: resolvers,
20 validationRules,
21 graphiql: true
22}));필드 레벨 권한 제어
typescript
1import { GraphQLFieldResolver } from 'graphql';
2
3function requireAuth(resolver: GraphQLFieldResolver<any, any>) {
4 return async (parent: any, args: any, context: any, info: any) => {
5 if (!context.user) {
6 throw new Error('인증이 필요합니다');
7 }
8 return resolver(parent, args, context, info);
9 };
10}
11
12function requireRole(role: string) {
13 return (resolver: GraphQLFieldResolver<any, any>) => {
14 return async (parent: any, args: any, context: any, info: any) => {
15 if (!context.user) {
16 throw new Error('인증이 필요합니다');
17 }
18 if (context.user.role !== role && context.user.role !== 'admin') {
19 throw new Error('권한이 없습니다');
20 }
21 return resolver(parent, args, context, info);
22 };
23 };
24}
25
26const resolvers = {
27 Query: {
28 users: requireAuth(async (parent, args, context) => {
29 return await User.find();
30 }),
31
32 adminData: requireRole('admin')(async (parent, args, context) => {
33 return await AdminData.find();
34 })
35 }
36};캐싱 전략
필드 레벨 캐싱
typescript
1import { GraphQLResolveInfo } from 'graphql';
2import { createHash } from 'crypto';
3import Redis from 'ioredis';
4
5const redis = new Redis(process.env.REDIS_URL);
6
7function getCacheKey(info: GraphQLResolveInfo, args: any): string {
8 const fieldName = info.fieldName;
9 const parentType = info.parentType.name;
10 const argsHash = createHash('md5')
11 .update(JSON.stringify(args))
12 .digest('hex');
13 return `graphql:${parentType}:${fieldName}:${argsHash}`;
14}
15
16async function cachedResolver<T>(
17 resolver: () => Promise<T>,
18 info: GraphQLResolveInfo,
19 args: any,
20 ttl: number = 300
21): Promise<T> {
22 const cacheKey = getCacheKey(info, args);
23
24 // 캐시 확인
25 const cached = await redis.get(cacheKey);
26 if (cached) {
27 return JSON.parse(cached);
28 }
29
30 // 리졸버 실행
31 const result = await resolver();
32
33 // 캐시 저장
34 await redis.setex(cacheKey, ttl, JSON.stringify(result));
35
36 return result;
37}
38
39// 사용 예시
40const resolvers = {
41 Query: {
42 user: async (parent, args, context, info) => {
43 return cachedResolver(
44 () => User.findById(args.id),
45 info,
46 args,
47 600 // 10분
48 );
49 }
50 }
51};HTTP 캐싱
typescript
1import { GraphQLResolveInfo } from 'graphql';
2import { setCacheControl } from 'graphql-cache-control';
3
4const resolvers = {
5 Query: {
6 user: setCacheControl({ maxAge: 300 })(async (parent, args) => {
7 return await User.findById(args.id);
8 }),
9
10 posts: setCacheControl({ maxAge: 60, scope: 'PRIVATE' })(async (parent, args) => {
11 return await Post.find();
12 })
13 }
14};서브스크립션 (Real-time)
typescript
1import { PubSub } from 'graphql-subscriptions';
2import { withFilter } from 'graphql-subscriptions';
3
4const pubsub = new PubSub();
5
6const resolvers = {
7 Subscription: {
8 postCreated: {
9 subscribe: withFilter(
10 () => pubsub.asyncIterator('POST_CREATED'),
11 (payload, variables) => {
12 // 필터링 로직
13 if (variables.authorId) {
14 return payload.postCreated.authorId === variables.authorId;
15 }
16 return true;
17 }
18 )
19 },
20
21 commentAdded: {
22 subscribe: withFilter(
23 () => pubsub.asyncIterator('COMMENT_ADDED'),
24 (payload, variables) => {
25 return payload.commentAdded.postId === variables.postId;
26 }
27 )
28 }
29 },
30
31 Mutation: {
32 createPost: async (parent, args, context) => {
33 const post = await Post.create(args.input);
34
35 // 이벤트 발행
36 await pubsub.publish('POST_CREATED', {
37 postCreated: post
38 });
39
40 return post;
41 }
42 }
43};에러 처리
typescript
1import { GraphQLError } from 'graphql';
2
3class AppError extends Error {
4 constructor(
5 message: string,
6 public code: string,
7 public statusCode: number = 400
8 ) {
9 super(message);
10 this.name = 'AppError';
11 }
12}
13
14function formatError(error: GraphQLError) {
15 if (error.originalError instanceof AppError) {
16 return {
17 message: error.message,
18 code: error.originalError.code,
19 statusCode: error.originalError.statusCode
20 };
21 }
22
23 // 프로덕션 환경에서는 상세 에러 숨김
24 if (process.env.NODE_ENV === 'production') {
25 return {
26 message: '내부 서버 오류가 발생했습니다',
27 code: 'INTERNAL_ERROR'
28 };
29 }
30
31 return {
32 message: error.message,
33 locations: error.locations,
34 path: error.path,
35 extensions: error.extensions
36 };
37}
38
39app.use('/graphql', graphqlHTTP({
40 schema,
41 rootValue: resolvers,
42 graphiql: true,
43 customFormatErrorFn: formatError
44}));성능 모니터링
typescript
1import { GraphQLResolveInfo } from 'graphql';
2
3function createMetricsResolver(resolver: any) {
4 return async (parent: any, args: any, context: any, info: GraphQLResolveInfo) => {
5 const start = Date.now();
6 const fieldName = info.fieldName;
7 const parentType = info.parentType.name;
8
9 try {
10 const result = await resolver(parent, args, context, info);
11 const duration = Date.now() - start;
12
13 // 메트릭 수집
14 metrics.recordTiming(`graphql.resolve.${parentType}.${fieldName}`, duration);
15 metrics.incrementCounter(`graphql.resolve.${parentType}.${fieldName}.success`);
16
17 return result;
18 } catch (error) {
19 const duration = Date.now() - start;
20 metrics.recordTiming(`graphql.resolve.${parentType}.${fieldName}`, duration);
21 metrics.incrementCounter(`graphql.resolve.${parentType}.${fieldName}.error`);
22 throw error;
23 }
24 };
25}결론
GraphQL API를 성공적으로 설계하고 최적화하기 위한 핵심 원칙:
- 명확한 스키마 설계: 타입과 관계를 명확히 정의
- N+1 문제 해결: DataLoader를 활용한 배칭
- 쿼리 복잡도 제한: 깊이와 복잡도 제한으로 DoS 방지
- 적절한 캐싱: 필드 레벨 캐싱으로 성능 향상
- 에러 처리: 일관된 에러 형식과 적절한 에러 메시지
- 성능 모니터링: 리졸버 성능 추적 및 최적화
이러한 전략들을 적용하면 확장 가능하고 효율적인 GraphQL API를 구축할 수 있습니다.