GraphQL 2

김기현·2022년 5월 21일
1
post-thumbnail

GraphQL API

이번 블로깅에서는 이전 블로그의 이론을 바탕으로 Apollo server을 사용해 API를 만드는 시간을 가지겠습니다.

Apollo Server

Apollo server은 GraphQL을 이해하는 서버입니다. 어떠한 백엔드 프레임워크를 사용하더라도 Apollo server을 최상단에 추가할 수 있습니다.

Setup

node repository를 다음의 명령어로 초기화합니다.

npm init -y

apollo-server와 graphQL을 설치합니다.

npm install apollo-server graphql

nodemon도 추가로 설치합니다. 더 나은 개발 환경을 만들 수 있습니다.

npm install nodemon -D


아래의 사진과 같이 서버와 .gitignore을 생성합니다.

touch server.js
touch .gitignore

gitignore에 node_modules을 추가합니다. 왜냐하면 git 레포지토리에 올라가기엔 너무 많습니다.

그 후 server.js에서 ApolloServergql을 import합니다.

import { ApolloServer, gql } from "apollo-server";

ApolloServer로 서버를 실행시키기 위해 다음과 같이 입력합니다.

const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => {
  console.log(`Running on ${url}`);
});

그 후 npm run dev로 서버가 실행되도록 합니다.

그러면 다음과 같은 에러가 발생합니다.
throw Error('Apollo Server requires either an existing schema, modules or typeDef
apollo-server는 존재하는 schema나 modules 또는 typeDefs를 가져야 한다고 나옵니다!

Query Type

서버를 실행할 때 나온 throw Error('Apollo Server requires either an existing schema, modules or typeDef 에러는 GraphQL이 data의 형태를 미리 알고 있어야 하기 때문에 발생하였습니다.

GraphQL은 URL의 집합인 REST API와는 달리 type들의 집합입니다. 그래서 GraphQL server에게 data의 type(shape)을 설명해주어야 합니다.

const typeDefs = gql``;

const server = new ApolloServer({ typeDefs });

이 때 다음의 에러가 발생합니다.

왜냐하면 type이 없기 때문입니다. 필수적으로 사용해야만 하는 type은 Query입니다.

const typeDefs = gql`
  type Query {
    text: String
  }
`;

text:String hello:String의 타입은 REST api에서 GET /text GET /hello와 같습니다. Query type안에 있는 모든 것들은 URL을 만드는 것과 같은 역할을 합니다.

Scalar & Root Types

서버를 실행하면 다음의 창이 나옵니다. Apollo는 polar studio를 가지고 있습니다.
Query type을 지정해주었기에 다음 사진과 같이 쿼리가 표시됩니다.

그리고 Query를 실행하면 아래의 사진과 같이 null이라는 결과를 return합니다. 그리고 사용자가 원하는 data를 만들어낼 수 있도록(null이라는 리턴 값을 벗어나도록) 실제 코드를 작성해야 합니다.

GraphQL 객체 타입에는 이름과 필드가 있지만 이 필드는 더욱 구체적인 데이터로 해석되어야 합니다. 그 때 Scalar 타입을 사용할 수 있습니다. 아래는 공식 문서의 Scalar 타입 정의입니다. 아래의 사진과 같이 allTweets field가 Tweet의 list type을 return하도록 해야 합니다. (allTweets의 속성에 따라 Scalar 타입 대신 새로 정의한 필드로 리턴하게 합니다.)그러면 studio에서 다음과 같이 정의할 수 있습니다.

allTweets: [Tweet] tweet: Tweet의 차이점은 allTweets은 모든 Tweet들을 보여주는 반면 tweet는 단 하나의 Tweet을 보여줍니다. (추가로 tweet(id:ID): Tweet는 tweet의 id를 함께 받게 됩니다. 이는 REST api에서 GET /api/v1/tweet/:id와 같은 역할을 합니다.)

Mutation type

이전에는 GraphQL에 type들을 정의하였고, 이제 data를 만들어야 합니다.

REST api에서는 tweet을 생성할 때 POST /api/v1/tweets과 같은 URL을 사용합니다. GraphQL에서는 POST DELETE PUT 등의 request와 같은 타입들은 Mutation type에 넣습니다.

user가 보내는 data로 mutate하는 동작들을 모두 넣습니다. user가 데이터를 보내고, data, DB, Cache, server 등 그 데이터로 mutation이 발생한다면 Mutation type에 있어야 합니다.
postTweet은 Query가 없으며 argument로 text와 userId를 필요로 하는 것을 볼 수 있습니다.

Non Nullable Type

아래의 코드에서 tweet은 Tweet OR null이라는 말입니다.

  type Query {
    tweet(id: ID): Tweet
  }

그래서 아래의 Response와 같이 null을 return해도 graphQL은 에러 없이 클라이언트에게 말을 하지 않고 있습니다. 아래는 Non Nullable Type으로 지정한 tweet 타입입니다.

  type Query {
    tweet(id: ID!): Tweet!
  }

이로써 클라이언트가 graphQL에게 tweet Query를 사용하고 싶으면 ID를 보내야만 한다고 지정합니다. 그리고 항상 Tweet을 return하라고 지정합니다.

Query Resolver

resolvers 함수는 데이터베이스에 액세스한 다음 데이터를 반환합니다. 이 객체는 정의한 type과 같은 형태여야만 합니다.

resolvers 객체를 생성한 후 Apollo서버에서 인식할 수 있도록 입력해줍니다.

const resolvers = {
  Query: {
    tweet() {
      console.log("Called");
      return null;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

allTweets resolver를 구현하기 이전에 다음과 같이 가짜 데이터를 입력해줍니다. Tweet type과 같은 형태로 데이터를 만듭니다.

const tweets = [
  {
    id: "1",
    text: "first one!",
  },
  {
    id: "2",
    text: "second one",
  },
];

그리고 다음의 코드로 allTweets라는 쿼리를 실행합니다.

const resolvers = {
  Query: {
    allTweets() {
      return tweets;
    },
  },
};

데이터에서 id와 text를 선택 후 가져오는 결과는 아래의 이미지와 같습니다.

그리고 user가 arguments를 보낼 때의 (예를 들어 id: "1") arguments은 항상 resolver function의 두번째 argument(args)가 됩니다. (resolver 함수는 1. parent(root or source), 2. args, 3. context, 4. info 순으로 받습니다.)

const resolvers = {
  Query: {
    tweet(root, args) {
      console.log(args)
      return null;
    },
  },
};

//{ id: '1' }

아래의 코드는 tweets라는 객체에 담긴 데이터의 id와 사용자가 argument로 보내준 id와 같은 tweet를 return하라는 코드입니다.
결과는 아래와 같습니다.

{
  "data": {
    "tweet": {
      "id": "1",
      "text": "first one!"
    }
  }
}

Mutation Resolver

아래는 user들이 tweet을 post하고 delete 하는 resolver입니다.

  • postTweet
const resolvers = {
  Query: {...
  },
  Mutation: {
    postTweet(_, { text, userId }) {
      const newTweet = {
        id: tweets.length + 1,
        text: text,
      };
      tweets.push(newTweet);
      return newTweet;
    },
  },
};

typeDefspostTweet(text: String, userId: ID): Tweet라고 정의된 Mutation에 인자로 text와 userId를 받도록 합니다. 그리고 newTweet에 id와 text를 지정 후 tweets라는 리스트에 push합니다. 항상 새 tweet을 주어야 하기 때문에 return을 하였습니다.

아래의 사진과 같이 mutation을 실행 후 query를 통해 모든 데이터를 가져왔습니다. id가 3,4번인 데이터는 실제 DB에 없기에 나중에 사라지는 데이터입니다.

  • deleteTweet
const resolvers = {
  Query: {...
  },
  Mutation: {
    postTweet(_, { text, userId }) {...
    },
    deleteTweet(_, { id }) {
      const tweet = tweets.find((tweet) => tweet.id === id);
      if (!tweet) {
        return false;
      }
      tweets = tweets.filter((tweet) => tweet.id !== id);
      return true;
    },
  },
};

resolver에서 첫번째 argument는 무시, 두번째 arguments는 typeDefs에 정의된 Mutation에서 id를 argument로 가져왔습니다. tweets에서 id가 mutation에서 받은 id와 같은 tweet을 찾습니다. 만약 tweet을 찾지 못한다면 false를 return합니다.

만약 찾았다면 삭제합니다. filter()를 사용해 삭제하려는 id와 같지 않은 tweet들로 filter를 거치면 같지 않은(남아있어야 하는) tweet이 새로운 array로 만들어집니다. (이 때문에 tweets 가짜 데이터베이스는 const가 아닌 let으로 설정하였습니다.)

Type Resolver

query나 mutation안에 있는 field 이외에도 resolver의 function도 만들 수 있습니다. 우선 user의 정보가 담긴 DB를 만듭니다.

let users = [
  {
    id: "1",
    firstName: "David",
    lastName: "Kim",
  },
];

그 후 resolvers의 Query에 DB의 정보를 return하도록 합니다.

  Query: {
    allTweets() {
      return tweets;
    },
    tweet(root, { id }) {
      return tweets.find((tweet) => tweet.id === id);
    },
    allUsers() {
      return users;
    },
  },

DB에는 fullName이라는 필드가 없어 에러가 날 것입니다. 이는 fullName의 resolver를 만들어 해결할 수 있습니다.

const typeDefs = gql`
  type User {
    id: ID!
    firstName: String!
    lastName: String!
    fullName: String!
  }

아래의 사진과 같이 User에 fullName필드를 생성합니다. 이 때 모든 return 값들은 "hello"라고 지정하였습니다.

분명 DB에는 fullName필드가 없지만 resolver에 User 항목에 fullName 필드를 만들어 "hello"를 return하도록 했더니, fullName 필드가 정상적으로 나오는 것을 아래 사진과 같이 볼 수 있습니다.
그리고 아래는 root를 console.log()했을 때의 값입니다.

그래서 root 속 firstName과 lastName을 받도록 하고 이어붙여주면 아래의 사진과 같은 결과를 내어줍니다.

  User: {
    fullName({ firstName, lastName }) {
      return `${firstName} ${lastName}`;
    },
  },

Relationships

해당 파트에서는 Users와 Tweets를 Tweet resolver을 통해 연결해보겠습니다.

우선 tweets 데이터에 userId를 채워줍니다.

let tweets = [
  {
    id: "1",
    text: "first one!",
    userId: "2",
  },
  {
    id: "2",
    text: "second one",
    userId: "1",
  },
];

allTweets는 데이터 tweets를 return하기에 { id: '1', text: 'first one!', userId: '2' }를 반환합니다. 그리고 root위치에 있으므로 { userId }을 통해 userId를 가져옵니다.

  Tweet: {
    author({ userId }) {
      return userId.find((user) => user.id === userId);
    },
  },

만약 author을 request한다면 graphQL은 tweets의 data에 author이 없는 것을 파악하고 author resolver을 찾아갑니다. 아래의 사진과 같은 결과를 출력합니다.

빼앰~

profile
피자, 코드, 커피를 사랑하는 피코커

0개의 댓글