728x90
반응형
1. GraphQL이란
GraphQL은 Facebook에서 개발한 API 쿼리 언어입니다. REST와 달리 클라이언트가 필요한 데이터만 정확히 요청할 수 있으며, 단일 엔드포인트(/graphql)를 통해 모든 데이터에 접근합니다.
1.1 GraphQL vs REST
REST:
GET /users/1
GET /users/1/posts
GET /posts/1/comments
→ 여러 번의 요청, 오버페칭/언더페칭 문제
GraphQL:
POST /graphql
query {
user(id: 1) {
name
posts {
title
comments {
content
}
}
}
}
→ 단일 요청으로 필요한 데이터만 조회
2. 프로젝트 설정
npm install express @apollo/server graphql
3. 기본 구조
3.1 스키마 정의
// schema.js
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: String!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post
deletePost(id: ID!): Boolean!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
input UpdatePostInput {
title: String
content: String
}
`;
module.exports = typeDefs;
3.2 리졸버 정의
// resolvers.js
const users = new Map();
const posts = new Map();
const comments = new Map();
let nextUserId = 1;
let nextPostId = 1;
const resolvers = {
Query: {
users: () => Array.from(users.values()),
user: (_, { id }) => users.get(id),
posts: () => Array.from(posts.values()),
post: (_, { id }) => posts.get(id)
},
Mutation: {
createUser: (_, { input }) => {
const user = {
id: String(nextUserId++),
...input,
createdAt: new Date().toISOString()
};
users.set(user.id, user);
return user;
},
updateUser: (_, { id, input }) => {
const user = users.get(id);
if (!user) return null;
const updated = { ...user, ...input };
users.set(id, updated);
return updated;
},
deleteUser: (_, { id }) => {
return users.delete(id);
},
createPost: (_, { input }) => {
const post = {
id: String(nextPostId++),
title: input.title,
content: input.content,
authorId: input.authorId,
createdAt: new Date().toISOString()
};
posts.set(post.id, post);
return post;
},
updatePost: (_, { id, input }) => {
const post = posts.get(id);
if (!post) return null;
const updated = { ...post, ...input };
posts.set(id, updated);
return updated;
},
deletePost: (_, { id }) => {
return posts.delete(id);
}
},
// 필드 리졸버
User: {
posts: (user) => {
return Array.from(posts.values())
.filter(post => post.authorId === user.id);
}
},
Post: {
author: (post) => users.get(post.authorId),
comments: (post) => {
return Array.from(comments.values())
.filter(comment => comment.postId === post.id);
}
},
Comment: {
author: (comment) => users.get(comment.authorId),
post: (comment) => posts.get(comment.postId)
}
};
module.exports = resolvers;
3.3 서버 설정
// index.js
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
async function startServer() {
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers
});
await server.start();
app.use(express.json());
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => ({
token: req.headers.authorization
})
}));
app.listen(4000, () => {
console.log('GraphQL 서버: http://localhost:4000/graphql');
});
}
startServer();
반응형
4. 쿼리 예시
4.1 데이터 조회
# 모든 사용자 조회
query {
users {
id
name
email
}
}
# 특정 사용자와 게시글 조회
query {
user(id: "1") {
name
email
posts {
title
content
}
}
}
# 변수 사용
query GetUser($userId: ID!) {
user(id: $userId) {
name
posts {
title
}
}
}
# Variables: { "userId": "1" }
4.2 데이터 변경
# 사용자 생성
mutation {
createUser(input: {
name: "홍길동"
email: "hong@example.com"
}) {
id
name
email
}
}
# 게시글 생성
mutation {
createPost(input: {
title: "첫 번째 글"
content: "내용입니다"
authorId: "1"
}) {
id
title
author {
name
}
}
}
# 변수 사용
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
# Variables: { "input": { "name": "홍길동", "email": "hong@example.com" } }
5. 고급 기능
5.1 인증/인가
// context에서 사용자 정보 추출
const server = new ApolloServer({
typeDefs,
resolvers
});
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
const decoded = jwt.verify(token, SECRET);
user = await findUserById(decoded.userId);
} catch (e) {
// 토큰 검증 실패
}
}
return { user };
}
}));
// 리졸버에서 인증 확인
const resolvers = {
Mutation: {
createPost: (_, { input }, context) => {
if (!context.user) {
throw new Error('Authentication required');
}
// 게시글 생성
}
}
};
5.2 디렉티브
// 스키마에 디렉티브 정의
const typeDefs = `#graphql
directive @auth on FIELD_DEFINITION
type Query {
publicData: String
privateData: String @auth
}
`;
// 디렉티브 구현
const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');
function authDirective(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
}
});
}
5.3 페이지네이션
// Cursor 기반 페이지네이션
const typeDefs = `#graphql
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
`;
const resolvers = {
Query: {
posts: (_, { first = 10, after }) => {
const allPosts = Array.from(posts.values());
let startIndex = 0;
if (after) {
const afterIndex = allPosts.findIndex(p => p.id === after);
startIndex = afterIndex + 1;
}
const slicedPosts = allPosts.slice(startIndex, startIndex + first);
return {
edges: slicedPosts.map(post => ({
cursor: post.id,
node: post
})),
pageInfo: {
hasNextPage: startIndex + first < allPosts.length,
hasPreviousPage: startIndex > 0,
startCursor: slicedPosts[0]?.id,
endCursor: slicedPosts[slicedPosts.length - 1]?.id
},
totalCount: allPosts.length
};
}
}
};
5.4 DataLoader (N+1 문제 해결)
npm install dataloader
const DataLoader = require('dataloader');
// DataLoader 생성
function createLoaders() {
return {
userLoader: new DataLoader(async (userIds) => {
// 배치로 사용자 조회
const usersData = await User.find({ _id: { $in: userIds } });
const userMap = new Map(usersData.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id));
}),
postsByUserLoader: new DataLoader(async (userIds) => {
const allPosts = await Post.find({ authorId: { $in: userIds } });
const postsMap = new Map();
for (const post of allPosts) {
if (!postsMap.has(post.authorId)) {
postsMap.set(post.authorId, []);
}
postsMap.get(post.authorId).push(post);
}
return userIds.map(id => postsMap.get(id) || []);
})
};
}
// Context에 loader 추가
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => ({
loaders: createLoaders()
})
}));
// 리졸버에서 사용
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
}
},
User: {
posts: (user, _, { loaders }) => {
return loaders.postsByUserLoader.load(user.id);
}
}
};
5.5 서브스크립션 (실시간 업데이트)
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const POST_CREATED = 'POST_CREATED';
const typeDefs = `#graphql
type Subscription {
postCreated: Post!
}
`;
const resolvers = {
Mutation: {
createPost: async (_, { input }) => {
const post = createPostInDB(input);
// 이벤트 발행
pubsub.publish(POST_CREATED, { postCreated: post });
return post;
}
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED])
}
}
};
// WebSocket 서버 설정
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
useServer({ schema }, wsServer);
httpServer.listen(4000);
6. 에러 처리
const { GraphQLError } = require('graphql');
const resolvers = {
Query: {
user: (_, { id }) => {
const user = users.get(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'USER_NOT_FOUND',
argumentName: 'id'
}
});
}
return user;
}
},
Mutation: {
createUser: (_, { input }) => {
// 이메일 중복 검사
const exists = Array.from(users.values())
.some(u => u.email === input.email);
if (exists) {
throw new GraphQLError('Email already exists', {
extensions: {
code: 'DUPLICATE_EMAIL',
email: input.email
}
});
}
// 생성 로직
}
}
};
// 에러 포맷팅
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// 에러 로깅
console.error(error);
// 내부 에러 숨기기
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new GraphQLError('Internal server error');
}
return error;
}
});
7. 테스트
// tests/graphql.test.js
const { createTestClient } = require('apollo-server-testing');
const { ApolloServer } = require('@apollo/server');
const server = new ApolloServer({ typeDefs, resolvers });
const { query, mutate } = createTestClient(server);
describe('GraphQL API', () => {
test('사용자 생성', async () => {
const CREATE_USER = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const result = await mutate({
mutation: CREATE_USER,
variables: {
input: { name: '홍길동', email: 'hong@example.com' }
}
});
expect(result.errors).toBeUndefined();
expect(result.data.createUser.name).toBe('홍길동');
});
test('사용자 조회', async () => {
const GET_USERS = `
query {
users {
id
name
}
}
`;
const result = await query({ query: GET_USERS });
expect(result.data.users).toBeDefined();
});
});
결론
GraphQL은 클라이언트가 필요한 데이터를 정확히 요청할 수 있는 강력한 쿼리 언어입니다. Apollo Server로 Node.js에서 쉽게 GraphQL API를 구축할 수 있습니다. 스키마에서 타입을 정의하고, 리졸버에서 데이터를 처리합니다. DataLoader로 N+1 문제를 해결하고, 서브스크립션으로 실시간 기능을 구현할 수 있습니다.
728x90
반응형
'Node.js' 카테고리의 다른 글
| Node.js의 RESTful API 만들기 (0) | 2026.03.12 |
|---|---|
| Node.js의 서버 사이드 렌더링(SSR) (0) | 2026.03.11 |
| Node.js의 Fastify 프레임워크 (1) | 2026.03.11 |
| Node.js의 NestJS 프레임워크 (0) | 2026.03.10 |
| Node.js의 Hapi.js 프레임워크 (0) | 2026.03.10 |