Node.js 인터뷰 질문 54

질문: Node.js에서 GraphQL을 구현하는 방법과 그 장점은 무엇인가요?

답변:

GraphQL은 Facebook에서 개발한 API를 위한 쿼리 언어 및 런타임으로, REST API의 한계를 극복하기 위해 설계되었습니다. Node.js 환경에서 GraphQL을 구현하면 클라이언트가 필요한 데이터만 정확히 요청할 수 있어 효율적인 데이터 전송이 가능해집니다.

1. GraphQL의 기본 개념

GraphQL은 세 가지 핵심 기능을 제공합니다:

  1. 쿼리(Query): 데이터 읽기 작업
  2. 뮤테이션(Mutation): 데이터 생성/수정/삭제 작업
  3. 구독(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);
    },
  },
};

리졸버 함수는 다음 매개변수를 받습니다:

  1. parent: 상위 객체 (해당 필드가 다른 타입의 필드에 대한 리졸버인 경우)
  2. args: 쿼리에서 전달된 인수
  3. context: 모든 리졸버가 공유하는 컨텍스트 객체
  4. 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의 한계

  1. 과다 페칭(Over-fetching): 필요 이상의 데이터를 받는 문제
  2. 과소 페칭(Under-fetching): 여러 엔드포인트를 호출해야 하는 문제
  3. API 버전 관리: 엔드포인트마다 버전 관리가 필요
  4. 문서화: 별도의 문서화 도구가 필요

GraphQL의 장점

  1. 필요한 데이터만 요청: 클라이언트가 정확히 필요한 데이터만 지정 가능
  2. 단일 요청: 여러 리소스를 하나의 요청으로 가져올 수 있음
  3. 강력한 타입 시스템: 스키마가 자체 문서화 기능을 제공
  4. 실시간 업데이트: 구독 기능을 통한 실시간 데이터 지원
  5. 점진적 마이그레이션: 기존 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을 구현하는 것은 다음과 같은 이점을 제공합니다:

  1. 효율적인 데이터 로딩: 클라이언트가 필요한 데이터만 정확히 요청하여 과다/과소 페칭 문제 해결

  2. 강력한 타입 시스템: 스키마에 기반한 타입 정의로 견고한 API 개발 가능

  3. 자체 문서화: 스키마가 자동으로 문서화되어 별도의 API 문서 관리 불필요

  4. 단일 엔드포인트: 모든 요청이 단일 엔드포인트로 처리되어 API 관리 단순화

  5. 실시간 기능: 구독 기능을 통한 실시간 데이터 업데이트 지원

  6. 프론트엔드 개발 속도 향상: 원하는 데이터 구조를 정확히 요청할 수 있어 프론트엔드 개발 효율성 증가

Apollo Server와 같은 도구를 사용하면 Node.js에서 GraphQL API를 빠르게 구축할 수 있으며, 데이터 로딩 최적화, 인증, 권한 부여, 실시간 업데이트 등의 고급 기능도 쉽게 구현할 수 있습니다. 또한 기존의 REST API와 함께 사용하거나 점진적으로 마이그레이션할 수 있어 도입 장벽이 낮다는 장점도 있습니다.

results matching ""

    No results matching ""