윤스페이스 : 일상을 기록합니다.

이 포스팅은 https://blog.strapi.io/strapi-next-setup/ 에 있는 포스팅을 번역한 것 입니다.

오역이나 의역을 지적해주시면 확인 후 수정하겠습니다.


이 포스팅은 'Next.JS(React)와 GraphQL로 Deliveroo 클론하기'의 연재작 입니다.

Next.JS(React)와 GraphQL로 Deliveroo 클론하기 | 01. 🏗️ 기본 셋업

Next.JS(React)와 GraphQL로 Deliveroo 클론하기 | 02. 🏠 음식점 리스트 만들기

Next.JS(React)와 GraphQL로 Deliveroo 클론하기 | 03. 🍔 음식 리스트 만들기

Next.JS(React)와 GraphQL로 Deliveroo 클론하기 | 04. 🔐 회원 인증

Next.JS(React)와 GraphQL로 Deliveroo 클론하기 | 05. 🛒 장바구니 만들기


🏠 음식점 리스트

이번 프로젝트에 음식점 리스트를 표시해야 합니다. 이 목록은 저번 포스팅에서 Strapi로 만들었던 API를 사용하려 합니다. API를 사용하기 전에 Strapi에서 몇가지 설정을 더 해줘야 합니다. 그러면 시작해보도록 하겠습니다.

 

컨텐츠 타입 정의하기

하나의 컨텐츠 타입은 '모델'이라 불려집니다. Strapi는 회원(User) 컨텐츠 타입을 기본적으로 제공하고 있습니다. 이번 단계에는 음식점 타입을 만들어줄겁니다. 

 

  • localhost:1337/admin/plugins/content-type-builder에 접속합니다.
  • '콘텐츠 타입 추가' 버튼을 클릭하세요.
  • 이름에 'restaurant'를 입력하고 저장합니다.
  • 아래와 같이 새 필드를 추가해주세요.
    name : 문자(String)
    description : 텍스트(Text)
    image : 미디어(Media)
  • 저장을 클릭해주세요.

아래 사진과 같이 타입이 설정되어야 합니다.

데이터 추가하기

컨텐츠 타입을 만들었으니 그에 맞는 데이터를 추가해보려 합니다. http://localhost:1337/admin/plugins/content-manager/restaurant/create로 접속해 최대한 많은 데이터를 추가해주세요. 많으면 많을수록 좋습니다.

API 액세스 허용하기

Strapi는 기본적으로 보안 솔루션을 제공하고 있습니다. '역할 & 권한' 탭에서 JWT 관련 설정을 만져줄 수 있습니다. 설정하기 전 API에 접근을 시도해보세요. (localhost:1337/restaurants) 아마 권한이 없다고 아래와 같이 표시될 것 입니다. 

{"statusCode":403,"error":"Forbidden","message":"Forbidden"}

 

그렇기에 권한설정이 필요합니다. 권한설정을 위해 (localhost:1337/admin/plugins/users-permissions/)에 접속한 후 'Public'을 클릭해줍니다. 'find'와 'findone'을 선택하고 '저장' 버튼을 눌러주세요.

이제 (localhost:1337/restaurants)에 접속해보면 생성한 데이터가 표시되어야 합니다.

GraphQL 활성화 하기

이번 튜토리얼에선 GraphQL을 통해 데이터를 불러오려 합니다. GraphQL 사용을 위해 (http://localhost:1337/admin/marketplace)로 이동합니다. GraphQL 카드에서 다운로드 버튼을 눌러주세요. 다운로드가 완료되면 서버가 재시작되고 다운로드 버튼이 '설치됨'으로 변경됩니다. (http://localhost:1337/graphql)로 이동해서 아래 사진과 같은 playground가 잘 나오는지 확인해주세요.

아래의 코드를 왼쪽 섹션에 붙여넣기 한 후 플레이 버튼을 눌러보세요. 입력해준 데이터가 정상적으로 출력되면 GraphQL이 정상적으로 활성화 된 걸 알 수 있습니다.

{
  restaurants {
    _id
    name
  }
}

 

음식점 표시하기

이제 음식점들의 데이터를 추가하고, GraphQL까지 활성화 했으니 Next로 만들고 있던 웹에서 데이터를 불러와봐야겠죠? 터미널에서 프론트엔트 디렉터리로 가고 아래의 명령어를 입력해줍니다.

 ~/project/nextjs-tutorial > npm install react-apollo next-apollo graphql gql recompose

 

Next로 만들고 있는 웹에 GraphQL을 연결시켜주기 위해서는 apollo를 사용할 예정입니다. Apollo와 Next를 연결하는 간단한 예제는 (https://github.com/adamsoffer/next-apollo-example)에서 확인 가능합니다.

 

루트에 lib 폴더를 생성하고 apollo.js 파일을 아래와 같은 코드로 생성해주세요.

import { HttpLink } from "apollo-link-http";
import { withData } from "next-apollo";

const config = {
  link: new HttpLink({
    uri: "http://localhost:1337/graphql", // Server URL (must be absolute)
  })
};
export default withData(config);

 

음식점 컴포넌트를 만들기 위해 components 폴더 안에 RestaurantList 폴더를 생성하고 그 안에 index.js 파일을 아래와 같은 코드로 생성해주세요.

import gql from "graphql-tag";
import Link from "next/link";
import { graphql } from "react-apollo";
import {
  Button,
  Card,
  CardBody,
  CardColumns,
  CardImg,
  CardSubtitle
} from "reactstrap";
import { CardText, CardTitle, Col, Row } from "reactstrap";

const RestaurantList = (
  { data: { loading, error, restaurants }, search },
  req
) => {
  if (error) return "Error loading restaurants";
  //if restaurants are returned from the GraphQL query, run the filter query
  //and set equal to variable restaurantSearch

  if (restaurants && restaurants.length) {
    //searchQuery
    const searchQuery = restaurants.filter(query =>
      query.name.toLowerCase().includes(search)
    );
    if (searchQuery.length != 0) {
      return (
        <div>
          <div className="h-100">
            {searchQuery.map(res => (
              <Card
                style={{ width: "30%", margin: "0 10px" }}
                className="h-100"
                key={res._id}
              >
                <CardImg
                  top={true}
                  style={{ height: 250 }}
                  src={`http://localhost:1337${res.image.url}`}
                />
                <CardBody>
                  <CardTitle>{res.name}</CardTitle>
                  <CardText>{res.description}</CardText>
                </CardBody>
                <div className="card-footer">
                  <Link
                    as={`/restaurants/${res._id}`}
                    href={`/restaurants?id=${res._id}`}
                  >
                    <a className="btn btn-primary">View</a>
                  </Link>
                </div>
              </Card>
            ))}
          </div>

          <style jsx global>
            {`
              a {
                color: white;
              }
              a:link {
                text-decoration: none;
                color: white;
              }
              a:hover {
                color: white;
              }
              .card-columns {
                column-count: 3;
              }
            `}
          </style>
        </div>
      );
    } else {
      return <h1>No Restaurants Found</h1>;
    }
  }
  return <h1>Loading</h1>;
};

const query = gql`
  {
    restaurants {
      _id
      name
      description
      image {
        url
      }
    }
  }
`;
RestaurantList.getInitialProps = async ({ req }) => {
  const res = await fetch("https://api.github.com/repos/zeit/next.js");
  const json = await res.json();
  return { stars: json.stargazers_count };
};
// The `graphql` wrapper executes a GraphQL query and makes the results
// available on the `data` prop of the wrapped component (RestaurantList)
export default graphql(query, {
  props: ({ data }) => ({
    data
  })
})(RestaurantList);

 

그리고 pages의 index.js 파일을 아래와 같이 수정합니다.

/* /pages/index.js */

import RestaurantList from "../components/RestaurantList";
import React from "react";

import {
  Alert,
  Button,
  Col,
  Input,
  InputGroup,
  InputGroupAddon,
  Row
} from "reactstrap";

class Index extends React.Component {
  constructor(props) {
    super(props);
    //query state will be passed to RestaurantList for the filter query
    this.state = {
      query: ""
    };
  }
  onChange(e) {
    //set the state = to the input typed in the search Input Component
    //this.state.query gets passed into RestaurantList to filter the results
    this.setState({ query: e.target.value.toLowerCase() });
  }
  render() {
    return (
      <div className="container-fluid">
        <Row>
          <Col>
            <div className="search">
              <InputGroup>
                <InputGroupAddon addonType="append"> Search </InputGroupAddon>
                <Input onChange={this.onChange.bind(this)} />
              </InputGroup>
            </div>
            <RestaurantList search={this.state.query} />
          </Col>
        </Row>
        <style jsx>
          {`
            .search {
              margin: 20px;
              width: 500px;
            }
          `}
        </style>
      </div>
    );
  }
}

export default Index;

 

_app.js 파일도 수정해줍니다.

import Layout from "../components/Layout";
import withData from "../lib/apollo";

import App, { Container } from "next/app";
import React from "react";

class MyApp extends App {
  static async getInitialProps({ Component, router, ctx }) {
    let pageProps = {};
    if (Component.getInitialProps) {
      pageProps = await Component.getInitialProps(ctx);
    }
    return { pageProps };
  }

  render() {
    const { Component, pageProps, isAuthenticated, ctx } = this.props;
    return (
      <Container>
        <Layout isAuthenticated={isAuthenticated} {...pageProps}>
          <Component {...pageProps} />
        </Layout>

        <style jsx global>
          {`
            a {
              color: white !important;
            }
            a:link {
              text-decoration: none !important;
              color: white !important;
            }
            a:hover {
              color: white;
            }
            .card {
              display: inline-block !important;
            }
            .card-columns {
              column-count: 3;
            }
          `}
        </style>
      </Container>
    );
  }
}
export default withData(MyApp);

 

이대로 서버를 재시작 하면 오류가 발생합니다.  '/node_modules/apollo-boost/lib/bundle.cjs.js' 파일을 아래의 코드로 수정해주세요.

3번째 줄만 주석처리 해주면 됩니다.

'use strict';

// Object.defineProperty(exports, '__esModule', { value: true });

function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }

var tslib = require('tslib');
var ApolloClient__default = require('apollo-client');
var ApolloClient__default__default = _interopDefault(ApolloClient__default);
var apolloLink = require('apollo-link');
var apolloCacheInmemory = require('apollo-cache-inmemory');
var apolloLinkHttp = require('apollo-link-http');
var apolloLinkError = require('apollo-link-error');
var graphqlTag = _interopDefault(require('graphql-tag'));
var tsInvariant = require('ts-invariant');

var PRESET_CONFIG_KEYS = [
    'request',
    'uri',
    'credentials',
    'headers',
    'fetch',
    'fetchOptions',
    'clientState',
    'onError',
    'cacheRedirects',
    'cache',
    'name',
    'version',
    'resolvers',
    'typeDefs',
    'fragmentMatcher',
];
var DefaultClient = (function (_super) {
    tslib.__extends(DefaultClient, _super);
    function DefaultClient(config) {
        if (config === void 0) { config = {}; }
        var _this = this;
        if (config) {
            var diff = Object.keys(config).filter(function (key) { return PRESET_CONFIG_KEYS.indexOf(key) === -1; });
            if (diff.length > 0) {
                process.env.NODE_ENV === "production" || tsInvariant.invariant.warn('ApolloBoost was initialized with unsupported options: ' +
                    ("" + diff.join(' ')));
            }
        }
        var request = config.request, uri = config.uri, credentials = config.credentials, headers = config.headers, fetch = config.fetch, fetchOptions = config.fetchOptions, clientState = config.clientState, cacheRedirects = config.cacheRedirects, errorCallback = config.onError, name = config.name, version = config.version, resolvers = config.resolvers, typeDefs = config.typeDefs, fragmentMatcher = config.fragmentMatcher;
        var cache = config.cache;
        process.env.NODE_ENV === "production" ? tsInvariant.invariant(!cache || !cacheRedirects) : tsInvariant.invariant(!cache || !cacheRedirects, 'Incompatible cache configuration. If providing `cache` then ' +
            'configure the provided instance with `cacheRedirects` instead.');
        if (!cache) {
            cache = cacheRedirects
                ? new apolloCacheInmemory.InMemoryCache({ cacheRedirects: cacheRedirects })
                : new apolloCacheInmemory.InMemoryCache();
        }
        var errorLink = errorCallback
            ? apolloLinkError.onError(errorCallback)
            : apolloLinkError.onError(function (_a) {
                var graphQLErrors = _a.graphQLErrors, networkError = _a.networkError;
                if (graphQLErrors) {
                    graphQLErrors.map(function (_a) {
                        var message = _a.message, locations = _a.locations, path = _a.path;
                        return process.env.NODE_ENV === "production" || tsInvariant.invariant.warn("[GraphQL error]: Message: " + message + ", Location: " +
                            (locations + ", Path: " + path));
                    });
                }
                if (networkError) {
                    process.env.NODE_ENV === "production" || tsInvariant.invariant.warn("[Network error]: " + networkError);
                }
            });
        var requestHandler = request
            ? new apolloLink.ApolloLink(function (operation, forward) {
                return new apolloLink.Observable(function (observer) {
                    var handle;
                    Promise.resolve(operation)
                        .then(function (oper) { return request(oper); })
                        .then(function () {
                        handle = forward(operation).subscribe({
                            next: observer.next.bind(observer),
                            error: observer.error.bind(observer),
                            complete: observer.complete.bind(observer),
                        });
                    })
                        .catch(observer.error.bind(observer));
                    return function () {
                        if (handle) {
                            handle.unsubscribe();
                        }
                    };
                });
            })
            : false;
        var httpLink = new apolloLinkHttp.HttpLink({
            uri: uri || '/graphql',
            fetch: fetch,
            fetchOptions: fetchOptions || {},
            credentials: credentials || 'same-origin',
            headers: headers || {},
        });
        var link = apolloLink.ApolloLink.from([errorLink, requestHandler, httpLink].filter(function (x) { return !!x; }));
        var activeResolvers = resolvers;
        var activeTypeDefs = typeDefs;
        var activeFragmentMatcher = fragmentMatcher;
        if (clientState) {
            if (clientState.defaults) {
                cache.writeData({
                    data: clientState.defaults,
                });
            }
            activeResolvers = clientState.resolvers;
            activeTypeDefs = clientState.typeDefs;
            activeFragmentMatcher = clientState.fragmentMatcher;
        }
        _this = _super.call(this, {
            cache: cache,
            link: link,
            name: name,
            version: version,
            resolvers: activeResolvers,
            typeDefs: activeTypeDefs,
            fragmentMatcher: activeFragmentMatcher,
        }) || this;
        return _this;
    }
    return DefaultClient;
}(ApolloClient__default__default));

Object.keys(ApolloClient__default).forEach(function (key) { exports[key] = ApolloClient__default[key]; });
Object.keys(apolloLink).forEach(function (key) { exports[key] = apolloLink[key]; });
Object.keys(apolloCacheInmemory).forEach(function (key) { exports[key] = apolloCacheInmemory[key]; });
exports.HttpLink = apolloLinkHttp.HttpLink;
exports.gql = graphqlTag;
exports.default = DefaultClient;
//# sourceMappingURL=bundle.cjs.js.map

 

아래와 같이 아까 입력한 음식점 데이터가 뜨면 잘 따라온겁니다.

 

다음에는 음식 리스트를 만드는 방법을 알아볼 예정입니다.

 

질문은 댓글로 남겨주시면 최대한 빠른 시간 안에 답변드리겠습니다.

 

포스팅 읽어주셔서 감사합니다 😁