[Spark It] (2) GraphQL 적용하기

HY·2022년 7월 3일
0

spark-it

목록 보기
2/4
post-thumbnail

GraphQL 선택

이번 프로젝트에서는 REST API 대신 GraphQL을 사용했다.
REST API를 사용했을 때는 백엔드의 입장에서 API 문서를 관리하기도 번거로웠고, 클라이언트 쪽에서 필요한 데이터가 변경되면 API를 수정해야 하는 불편함이 있었다.
GraphQL이 이런 문제점을 해결해준다 하여 노마드코더의 강의(https://nomadcoders.co/graphql-for-beginners/lobby)를 수강하고 적용하였다.
나는 이번 프로젝트에서 백엔드를 맡았기 때문에 서버를 구축하고 GraphQL API를 만들었다.

GraphQL의 장점

간단하게 GraphQL의 장점을 정리하고 넘어간다.
GraphQL에서는 클라이언트에서 단순히 서버에서 정보를 조회하기 위해 필요한 파라미터만 보내는 것이 아니라 클라이언트에서 원하는 정보를 구체적으로 요청한다.
그렇기 때문에 서버에서는 일일이 요청에 따라 전송해줄 API를 따로 만들 필요가 없고, 클라이언트에선 필요한 것 이상으로 정보를 많이 받을 필요가 없어 필요한 데이터만 받아 처리할 수 있다.

서버 구축

express와 apollo server를 함께 사용하기 위해 apollo-server-express 패키지를 설치해 구현하였다.

// server.ts

import express from "express";
import "reflect-metadata";
import { ApolloServer } from "apollo-server-express";
import http from "http";
import { ApolloServerPluginDrainHttpServer } from "apollo-server-core";
import { sequelize } from "./models";
import { resolvers, typeDefs } from "./graphql/schema"; // resolvers, typeDefs 취합
import Web3 from "web3"; // 토큰 전송
export const web3 = new Web3(Web3.givenProvider || "ws://localhost:8545");
import dotenv from "dotenv";
dotenv.config();

const SPARK_IT_SERVER_PORT = 4000;
const app = express();

const httpServer = http.createServer(app);
const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  csrfPrevention: true,
  cache: "bounded",
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});

async function initApolloServer() {
  await apolloServer.start();
  // apollo server에 express 연동
  apolloServer.applyMiddleware({ app });
  await new Promise<void>((resolve) =>
    httpServer.listen({ port: SPARK_IT_SERVER_PORT }, resolve)
  );
  console.log(
    `🚀 Server ready at http://localhost:${SPARK_IT_SERVER_PORT}${apolloServer.graphqlPath}`
  );
  //DB 싱크
  await sequelize
    .sync({ force: false }) // force:true 로 변경시 서버 재시작 할 때마다 테이블 삭제
    .then(() => {
      console.log("seq connection success");
    })
    .catch((e: Error) => {
      console.log("seq ERROR: ", e);
    });
}

void initApolloServer();

TypeDefs, Resolvers 분류 및 취합

API를 크게 댓글, 해시태그, 좋아요, 포스트, 유저로 하였다.
각각 디렉토리를 생성하고 관련된 TypeDefs, Resolver를 만들어 schema.ts에서 취합하였다.

.
├── comment
│   ├── comment.resolvers.ts
│   └── comment.typeDefs.ts
├── hashtag
│   ├── hashtag.resolvers.ts
│   └── hashtag.typeDefs.ts
├── like
│   ├── like.resolvers.ts
│   └── like.typeDefs.ts
├── post
│   ├── post.resolvers.ts
│   └── post.typeDefs.ts
├── schema.ts
├── tree.log
└── user
    ├── user.resolvers.ts
    └── user.typeDefs.ts
// schema.ts

import { loadFilesSync } from "@graphql-tools/load-files";
import { mergeTypeDefs, mergeResolvers } from "@graphql-tools/merge";

const loadedTypes = loadFilesSync(`${__dirname}/**/*.typeDefs.ts`);

const loadedResolvers = loadFilesSync(`${__dirname}/**/*.resolvers.ts`);

export const typeDefs = mergeTypeDefs(loadedTypes);
export const resolvers = mergeResolvers(loadedResolvers);

@graphql-tools/merge를 사용하기 전에 react-merge를 사용해 취합하려고 시도했는데, 이름이 겹치는 경우 제대로 취합되지 않는 이슈가 있었다.
@graphql-tools/merge를 사용하여 각각의 typeDefs와 resolver를 취합하니 정상적으로 작동하였다.

API

GraphQL을 사용하면서 가장 만족스러웠던 기능은 리졸버 체인 기능이었다.
SQL을 사용했더라면 일일이 join을 사용했어야 했을 하위 항목의 정보를 타입으로 명시만 해두면 알아서 조회를 할 수 있었다.
백엔드에서 API를 만들 때도 편리했고, 프론트엔드에서 데이터를 요청할 때도 사용하기 편리했다.
apollographql에서 제공하는 예시 코드는 다음과 같다.

# A library has a branch and books
type Library {
  branch: String!
  books: [Book!]
}

# A book has a title and author
type Book {
  title: String!
  author: Author!
}

# An author has a name
type Author {
  name: String!
}

type Query {
  libraries: [Library]
}

이 리졸버 체인 기능을 프로젝트에서 포스팅을 작성한 유저의 정보와 좋아요를 누른 사람들의 정보 조회에 사용할 수 있었다.

//src/post/post.typeDefs.ts
import { gql } from "apollo-server-express";

export default gql`
  type Post {
    id: Int
    title: String
    post_content: String
    user_id: Int
    created_at: String
    hashtags: [Hashtag] // 다른 테이블에 저장한 데이터를 리졸버 체인으로 가져오도록 명시한다.
    comments: [Comment]
    writer: User
    likes: [Like]
    images: [Image]
  }

  type User {
    id: Int
    nickname: String
    email: String
  }

  type Comment {
    id: Int
    comment: String
    post_id: Int
    user_id: Int
    writer: User
  }

  type Hashtag {
    id: Int
    hashtag: String
  }

  type Image {
    id: Int
    image_path: String
    post_id: Int
  }

  type Like {
    id: Int
    post_id: Int
    user_id: Int
  }

  type Query {
    getPosts: [Post]
    getPost(post_id: Int): Post
    getPostsByHashtag(hashtag_id: Int): [Post]
  }

  type Mutation {
    createPost(
      title: String!
      post_content: String!
      user_id: Int
      hashtags: [String]
      images: [String]
      access_token: String
    ): Int!
  }
`;
// src/post/post.resolvers.ts

import postModel from "../../models/post.model";
import hashtagModel from "../../models/hashtag.model";
import postHashtagModel from "../../models/post.hashtag.model";
import userModel from "../../models/user.model";
import likeModel from "../../models/like.model";
import commentModel from "../../models/comment.model";
import imageModel from "../../models/image.model";
import { status } from "../../constants/code";
import { sequelize } from "../../models/index";
import { sendTokenToWriter } from "../../token/tokenUtil";
import { verifyAccessToken } from "../../utils/jwt";

type inputPost = {
  title: string;
  post_content: string;
  user_id: number;
  hashtags: [string];
  images: [string];
  access_token: string;
};

type post = {
  id: number;
  title: string;
  post_content: string;
  user_id: number;
  created_at: string;
};

type hashtag = {
  id: number;
  hashtag: string;
};

type comment = {
  id: number;
  comment: string;
  post_id: number;
  user_id: number;
};

type user = {
  id: number;
  email: string;
  nickname: string;
};

type image = {
  id: number;
  image_path: string;
  post_id: number;
};

type like = {
  id: number;
  post_id: number;
  user_id: number;
};

export default {
  Post: {
    async hashtags(root: any) {
      const getHashtagsQuery = `SELECT hashtags.* FROM hashtags, posts_hashtags where hashtags.id = posts_hashtags.hashtag_id and posts_hashtags.post_id = :post_id`;
      const getHashtagsValue = {
        post_id: root.id,
      };
      const hashtags = await sequelize.query(getHashtagsQuery, {
        replacements: getHashtagsValue,
      });
      return hashtags[0];
    },
    async comments(root: any) {
      let comments = await commentModel.findAll({
        where: {
          post_id: root.id,
        },
      });
      return comments;
    },
    async writer(root: any) {
      let userInfo = await userModel.findOne({
        where: {
          id: root.user_id,
        },
      });
      return userInfo;
    },
    async likes(root: any) {
      let likes = await likeModel.findAll({
        where: {
          post_id: root.id,
        },
      });
      return likes;
    },
    async images(root: any) {
      let images = await imageModel.findAll({
        where: {
          post_id: root.id,
        },
      });
      return images;
    },
  },
  Comment: {
    async writer(root: any) {
      let userInfo = await userModel.findOne({
        where: {
          id: root.user_id,
        },
      });
      return userInfo;
    },
  },
  Query: {
    async getPosts() {
      let posts = await postModel.findAll();
      return posts;
    },
    async getPost(_: any, args: { post_id: number }) {
      let postInfo = await postModel.findOne({
        where: {
          id: args.post_id,
        },
      });
      return postInfo;
    },
    async getPostsByHashtag(_: any, args: { hashtag_id: number }) {
      const getPostsByHashtagQuery = `select posts.* FROM posts, posts_hashtags where posts_hashtags.hashtag_id = :hashtag_id and posts.id = posts_hashtags.post_id`;
      const getPostsByHashtagValue = {
        hashtag_id: args.hashtag_id,
      };
      const posts = await sequelize.query(getPostsByHashtagQuery, {
        replacements: getPostsByHashtagValue,
      });
      return posts[0];
    },
  },
  Mutation: {
    async createPost(_: any, args: inputPost) {
      if (!verifyAccessToken(args.access_token)) {
        return status.TOKEN_EXPIRED;
      }

      let post = await postModel.create({
        title: args.title,
        post_content: args.post_content,
        user_id: args.user_id,
      });

      if (!post) {
        return status.SERVER_ERROR;
      }

      let userInfo = await userModel.findOne({
        where: {
          id: args.user_id,
        },
      });
      if (userInfo) {
        sendTokenToWriter(userInfo.account, userInfo.id);
      }

      if (args.images != null && args.images.length > 0) {
        for (let image of args.images) {
          let savedImage = await imageModel.create({
            image_path: image,
            post_id: post.id,
          });
          if (!savedImage) {
            return status.SERVER_ERROR;
          }
        }
      }

      if (args.hashtags != null && args.hashtags.length > 0) {
        for (let inputHashtag of args.hashtags) {
          var hashtag = await hashtagModel.findOne({
            where: {
              hashtag: inputHashtag,
            },
          });
          if (!hashtag) {
            hashtag = await hashtagModel.create({
              hashtag: inputHashtag,
            });
          }
          await postHashtagModel.create({
            post_id: post.id,
            hashtag_id: hashtag.id,
          });
        }
      }

      return post.id;
    },
  },
};

Apollo Studio 테스트

참고 문헌

https://www.apollographql.com/docs/apollo-server/integrations/middleware/
https://www.apollographql.com/docs/apollo-server/data/resolvers/

profile
사실은 공부를 비밀스럽게 하고 싶었다

0개의 댓글