Node.js 인터뷰 질문 54
질문: Node.js에서 GraphQL을 구현하는 방법과 그 장점은 무엇인가요?
답변:
GraphQL은 Facebook에서 개발한 API를 위한 쿼리 언어 및 런타임으로, REST API의 한계를 극복하기 위해 설계되었습니다. Node.js 환경에서 GraphQL을 구현하면 클라이언트가 필요한 데이터만 정확히 요청할 수 있어 효율적인 데이터 전송이 가능해집니다.
1. GraphQL의 기본 개념
GraphQL은 세 가지 핵심 기능을 제공합니다:
- 쿼리(Query): 데이터 읽기 작업
- 뮤테이션(Mutation): 데이터 생성/수정/삭제 작업
- 구독(Subscription): 실시간 업데이트를 위한 작업
GraphQL 스키마 예시
type User {
id: ID!
name: String!
email: String!
posts: [Post!]
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
}
type Subscription {
newPost: Post!
}
2. Node.js에서 GraphQL 구현하기
Apollo Server 사용 방법
Apollo Server는 Node.js에서 GraphQL 서버를 구현하는 가장 인기 있는 방법 중 하나입니다.
2.1 설치
npm install apollo-server graphql
2.2 기본 서버 설정
const { ApolloServer, gql } = require("apollo-server");
// 스키마 정의
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
}
`;
// 임시 데이터
const users = [
{ id: "1", name: "홍길동", email: "hong@example.com" },
{ id: "2", name: "김철수", email: "kim@example.com" },
];
// 리졸버 함수 정의
const resolvers = {
Query: {
users: () => users,
user: (_, { id }) => users.find((user) => user.id === id),
},
Mutation: {
createUser: (_, { name, email }) => {
const id = String(users.length + 1);
const newUser = { id, name, email };
users.push(newUser);
return newUser;
},
},
};
// 서버 생성 및 시작
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
console.log(`🚀 서버 실행 중: ${url}`);
});
2.3 Express와 통합
Express.js와 Apollo Server를 함께 사용하면 더 유연한 구성이 가능합니다.
const express = require("express");
const { ApolloServer } = require("apollo-server-express");
const { ApolloServerPluginDrainHttpServer } = require("apollo-server-core");
const http = require("http");
async function startApolloServer(typeDefs, resolvers) {
// Express 앱 생성
const app = express();
// HTTP 서버 생성
const httpServer = http.createServer(app);
// Apollo 서버 생성
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});
// Apollo 서버 시작
await server.start();
// Express에 미들웨어로 적용
server.applyMiddleware({ app });
// 서버 시작
await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve));
console.log(`🚀 서버 실행 중: http://localhost:4000${server.graphqlPath}`);
return { server, app, httpServer };
}
// 서버 시작
startApolloServer(typeDefs, resolvers);
3. GraphQL 리졸버 작성하기
리졸버는 GraphQL 쿼리가 들어왔을 때 실제 데이터를 반환하는 함수입니다.
3.1 기본 리졸버 구조
const resolvers = {
Query: {
// Query 필드에 대한 리졸버
user: (parent, args, context, info) => {
// args.id를 사용하여 사용자 조회
return context.dataSources.userAPI.getUser(args.id);
},
},
Mutation: {
// Mutation 필드에 대한 리졸버
createUser: (parent, args, context, info) => {
return context.dataSources.userAPI.createUser(args);
},
},
// 타입 필드에 대한 리졸버
User: {
posts: (parent, args, context, info) => {
// parent.id는 User 객체의 id
return context.dataSources.postAPI.getPostsByAuthor(parent.id);
},
},
};
리졸버 함수는 다음 매개변수를 받습니다:
parent
: 상위 객체 (해당 필드가 다른 타입의 필드에 대한 리졸버인 경우)args
: 쿼리에서 전달된 인수context
: 모든 리졸버가 공유하는 컨텍스트 객체info
: 쿼리의 실행 상태에 대한 정보
3.2 데이터베이스와 통합하기 (Mongoose 예시)
const mongoose = require("mongoose");
// MongoDB 모델 정의
const UserSchema = new mongoose.Schema({
name: String,
email: String,
});
const PostSchema = new mongoose.Schema({
title: String,
content: String,
authorId: mongoose.Schema.Types.ObjectId,
});
const User = mongoose.model("User", UserSchema);
const Post = mongoose.model("Post", PostSchema);
// 리졸버
const resolvers = {
Query: {
users: async () => await User.find({}),
user: async (_, { id }) => await User.findById(id),
posts: async () => await Post.find({}),
post: async (_, { id }) => await Post.findById(id),
},
Mutation: {
createUser: async (_, { name, email }) => {
const user = new User({ name, email });
return await user.save();
},
createPost: async (_, { title, content, authorId }) => {
const post = new Post({ title, content, authorId });
return await post.save();
},
},
User: {
posts: async (parent) => await Post.find({ authorId: parent.id }),
},
Post: {
author: async (parent) => await User.findById(parent.authorId),
},
};
4. 데이터 소스 분리하기
Apollo Server에서는 데이터 소스를 분리하여 관리할 수 있습니다.
const { ApolloServer } = require("apollo-server");
const { MongoDataSource } = require("apollo-datasource-mongodb");
// MongoDB 데이터 소스 클래스
class Users extends MongoDataSource {
async getUser(id) {
return await this.findOneById(id);
}
async getUsers() {
return await this.model.find({});
}
async createUser({ name, email }) {
const user = new this.model({ name, email });
return await user.save();
}
}
class Posts extends MongoDataSource {
async getPost(id) {
return await this.findOneById(id);
}
async getPosts() {
return await this.model.find({});
}
async getPostsByAuthor(authorId) {
return await this.model.find({ authorId });
}
async createPost({ title, content, authorId }) {
const post = new this.model({ title, content, authorId });
return await post.save();
}
}
// 서버 설정
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
users: new Users(User),
posts: new Posts(Post),
}),
});
5. 인증 및 권한 부여
GraphQL 서버에 인증 및 권한 부여를 추가하는 방법입니다.
const { ApolloServer } = require("apollo-server-express");
const jwt = require("jsonwebtoken");
const express = require("express");
// 컨텍스트 함수
const context = ({ req }) => {
// 헤더에서 토큰 가져오기
const token = req.headers.authorization || "";
// 토큰 검증
try {
if (token) {
const user = jwt.verify(
token.replace("Bearer ", ""),
process.env.JWT_SECRET
);
return { user };
}
} catch (error) {
console.error("인증 오류:", error);
}
return { user: null };
};
// 인증 확인 헬퍼 함수
const checkAuth = (context) => {
if (!context.user) {
throw new Error("인증이 필요합니다");
}
return context.user;
};
// 리졸버
const resolvers = {
Query: {
me: (_, __, context) => {
const user = checkAuth(context);
return context.dataSources.users.getUser(user.id);
},
// 공개 접근 쿼리
publicUsers: (_, __, context) => {
return context.dataSources.users.getUsers();
},
},
Mutation: {
login: async (_, { email, password }, context) => {
const user = await context.dataSources.users.getUserByEmail(email);
// 비밀번호 검증 로직 (실제로는 bcrypt 등 사용)
if (!user || password !== user.password) {
throw new Error("잘못된 이메일 또는 비밀번호");
}
// JWT 토큰 생성
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "1d" }
);
return { token, user };
},
// 인증이 필요한 뮤테이션
createPost: (_, args, context) => {
const user = checkAuth(context);
return context.dataSources.posts.createPost({
...args,
authorId: user.id,
});
},
},
};
// Apollo 서버 생성
const server = new ApolloServer({
typeDefs,
resolvers,
context,
dataSources: () => ({
users: new Users(User),
posts: new Posts(Post),
}),
});
6. 실시간 업데이트 (구독)
GraphQL 구독을 사용하여 실시간 업데이트를 구현할 수 있습니다.
const { ApolloServer, PubSub } = require("apollo-server");
// PubSub 인스턴스 생성
const pubsub = new PubSub();
// 이벤트 타입
const NEW_POST = "NEW_POST";
// 타입 정의
const typeDefs = gql`
type Subscription {
newPost: Post
}
# 기존 타입 정의...
`;
// 리졸버
const resolvers = {
Subscription: {
newPost: {
subscribe: () => pubsub.asyncIterator([NEW_POST]),
},
},
Mutation: {
createPost: async (_, args, context) => {
const user = checkAuth(context);
const post = await context.dataSources.posts.createPost({
...args,
authorId: user.id,
});
// 새 포스트 생성 이벤트 발행
pubsub.publish(NEW_POST, { newPost: post });
return post;
},
},
// 기존 리졸버...
};
// Apollo Server 설정 (WebSocket 지원 포함)
const server = new ApolloServer({
typeDefs,
resolvers,
context,
dataSources: () => ({
users: new Users(User),
posts: new Posts(Post),
}),
subscriptions: {
path: "/graphql",
},
});
// HTTP 및 WebSocket 서버 시작
server.listen().then(({ url, subscriptionsUrl }) => {
console.log(`🚀 서버 실행 중: ${url}`);
console.log(`🚀 구독 실행 중: ${subscriptionsUrl}`);
});
7. N+1 문제 해결하기
GraphQL에서 자주 발생하는 N+1 쿼리 문제를 해결하는 방법입니다.
const { ApolloServer } = require("apollo-server");
const DataLoader = require("dataloader");
// 서버 설정
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => {
// DataLoader 인스턴스 생성
const usersLoader = new DataLoader(async (ids) => {
const users = await User.find({ _id: { $in: ids } });
// id 순서에 맞게 정렬
return ids.map((id) =>
users.find((user) => user.id.toString() === id.toString())
);
});
const postsLoader = new DataLoader(async (authorIds) => {
const posts = await Post.find({ authorId: { $in: authorIds } });
// authorId 별로 그룹화
return authorIds.map((authorId) =>
posts.filter((post) => post.authorId.toString() === authorId.toString())
);
});
return { usersLoader, postsLoader };
},
});
// 리졸버 수정
const resolvers = {
// ...
User: {
posts: async (parent, _, context) => {
return await context.postsLoader.load(parent.id);
},
},
Post: {
author: async (parent, _, context) => {
return await context.usersLoader.load(parent.authorId);
},
},
};
8. GraphQL 스키마 모듈화
대규모 GraphQL API의 스키마를 모듈화하는 방법입니다.
// src/schema/user.js
const { gql } = require("apollo-server");
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
posts: [Post!]
}
extend type Query {
users: [User!]!
user(id: ID!): User
me: User
}
extend type Mutation {
createUser(name: String!, email: String!, password: String!): User!
login(email: String!, password: String!): AuthPayload!
}
type AuthPayload {
token: String!
user: User!
}
`;
const resolvers = {
Query: {
users: async (_, __, { dataSources }) => await dataSources.users.getUsers(),
user: async (_, { id }, { dataSources }) =>
await dataSources.users.getUser(id),
me: async (_, __, { user, dataSources }) => {
if (!user) return null;
return await dataSources.users.getUser(user.id);
},
},
Mutation: {
// 사용자 생성 및 로그인 리졸버
},
User: {
posts: async (parent, _, { dataSources }) =>
await dataSources.posts.getPostsByAuthor(parent.id),
},
};
module.exports = { typeDefs, resolvers };
// src/schema/post.js
const { gql } = require("apollo-server");
const { PubSub } = require("graphql-subscriptions");
const pubsub = new PubSub();
const NEW_POST = "NEW_POST";
const typeDefs = gql`
type Post {
id: ID!
title: String!
content: String!
author: User!
}
extend type Query {
posts: [Post!]!
post(id: ID!): Post
}
extend type Mutation {
createPost(title: String!, content: String!): Post!
}
extend type Subscription {
newPost: Post!
}
`;
const resolvers = {
Query: {
posts: async (_, __, { dataSources }) => await dataSources.posts.getPosts(),
post: async (_, { id }, { dataSources }) =>
await dataSources.posts.getPost(id),
},
Mutation: {
createPost: async (_, args, { user, dataSources }) => {
if (!user) throw new Error("인증이 필요합니다");
const post = await dataSources.posts.createPost({
...args,
authorId: user.id,
});
pubsub.publish(NEW_POST, { newPost: post });
return post;
},
},
Subscription: {
newPost: {
subscribe: () => pubsub.asyncIterator([NEW_POST]),
},
},
Post: {
author: async (parent, _, { dataSources }) =>
await dataSources.users.getUser(parent.authorId),
},
};
module.exports = { typeDefs, resolvers, NEW_POST, pubsub };
// src/schema/index.js
const { gql } = require("apollo-server");
const userSchema = require("./user");
const postSchema = require("./post");
const { merge } = require("lodash");
// 기본 스키마
const baseTypeDefs = gql`
type Query {
_: Boolean
}
type Mutation {
_: Boolean
}
type Subscription {
_: Boolean
}
`;
// 스키마 병합
const typeDefs = [baseTypeDefs, userSchema.typeDefs, postSchema.typeDefs];
// 리졸버 병합
const resolvers = merge({}, userSchema.resolvers, postSchema.resolvers);
module.exports = { typeDefs, resolvers };
9. REST API와 GraphQL 비교
REST API의 한계
- 과다 페칭(Over-fetching): 필요 이상의 데이터를 받는 문제
- 과소 페칭(Under-fetching): 여러 엔드포인트를 호출해야 하는 문제
- API 버전 관리: 엔드포인트마다 버전 관리가 필요
- 문서화: 별도의 문서화 도구가 필요
GraphQL의 장점
- 필요한 데이터만 요청: 클라이언트가 정확히 필요한 데이터만 지정 가능
- 단일 요청: 여러 리소스를 하나의 요청으로 가져올 수 있음
- 강력한 타입 시스템: 스키마가 자체 문서화 기능을 제공
- 실시간 업데이트: 구독 기능을 통한 실시간 데이터 지원
- 점진적 마이그레이션: 기존 API와 함께 사용 가능
예시 비교
REST API로 사용자와 그의 포스트를 가져오는 경우:
GET /users/123 // 사용자 정보
GET /users/123/posts // 사용자의 포스트
GraphQL로 동일한 데이터를 가져오는 경우:
query {
user(id: "123") {
name
email
posts {
title
content
}
}
}
10. GraphQL 성능 최적화
쿼리 복잡성 제한
const { ApolloServer } = require("apollo-server");
const { createComplexityLimitRule } = require("graphql-validation-complexity");
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 10,
}),
],
});
쿼리 캐싱
const { ApolloServer } = require("apollo-server-express");
const { RedisCache } = require("apollo-server-cache-redis");
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new RedisCache({
host: "redis-server",
port: 6379,
}),
cacheControl: {
defaultMaxAge: 60, // 기본 캐시 시간 (초)
},
});
자동 영구화 쿼리
const { ApolloServer } = require("apollo-server-express");
const { MemcachedCache } = require("apollo-server-cache-memcached");
const responseCachePlugin = require("apollo-server-plugin-response-cache");
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new MemcachedCache(["memcached-server:11211"], {
retries: 3,
retry: 1000,
}),
},
plugins: [responseCachePlugin()],
});
요약
Node.js에서 GraphQL을 구현하는 것은 다음과 같은 이점을 제공합니다:
효율적인 데이터 로딩: 클라이언트가 필요한 데이터만 정확히 요청하여 과다/과소 페칭 문제 해결
강력한 타입 시스템: 스키마에 기반한 타입 정의로 견고한 API 개발 가능
자체 문서화: 스키마가 자동으로 문서화되어 별도의 API 문서 관리 불필요
단일 엔드포인트: 모든 요청이 단일 엔드포인트로 처리되어 API 관리 단순화
실시간 기능: 구독 기능을 통한 실시간 데이터 업데이트 지원
프론트엔드 개발 속도 향상: 원하는 데이터 구조를 정확히 요청할 수 있어 프론트엔드 개발 효율성 증가
Apollo Server와 같은 도구를 사용하면 Node.js에서 GraphQL API를 빠르게 구축할 수 있으며, 데이터 로딩 최적화, 인증, 권한 부여, 실시간 업데이트 등의 고급 기능도 쉽게 구현할 수 있습니다. 또한 기존의 REST API와 함께 사용하거나 점진적으로 마이그레이션할 수 있어 도입 장벽이 낮다는 장점도 있습니다.