New Dream Story 페이지

다음 내용을 실행하려면 반드시 앞서에서 수정한 컨트랙트를 사용해야 합니다.

index 페이지는 이제 그럴싸하게 보입니다. 다음으로 "Create a Story" 버튼을 눌렀을 때 보여지는 페이지를 만들어 보겠습니다. 이번에도 설명을 위해 코드가 변해가는 내용을 보여드릴 것입니다.
오래전에 다음과 같이 페이지를 디자인했었습니다. 조금 변경될테지만 이와 같은 페이지를 만들어 보겠습니다.


페이지를 만드는 것은 index 페이지를 만드는 것과 유사합니다. 그런데, 중요한 것은 새로 만드는 페이지에 어떻게 접근하느냐 입니다. 여기서 페이지 라우팅 문제가 발생합니다. index 페이지는 별다른 입력 없이 접근가능했지만 다른 페이지는 그렇지가 않습니다.

Next.js에서는 페이지 라우팅을 매우 쉽게 할 수가 있습니다. 다음과 같이 pages 폴더 밑에 페이지를 만들면 됩니다. 네스팅 구조라고 하죠? 폴더 밑에 또 폴더가 있는 그런 구조를 만들고 싶다면 단순히 pages 폴더 밑에 폴더를 만들고 그 안에 페이지 파일을 만들면 됩니다.
image.png


여기서 가장 중요한 것은 반드시 폴더명이 pages 이어야만 합니다.
그림과 같이 dream_stories라는 폴더를 만들고 그 안에 new_story.js파일을 만듭니다. 사실 이전에 pages 밑에 만들어놨었는데, 이동시킵니다. 그 후 다음과 같은 코드를 입력합니다.

// import react
import React, { Component } from 'react';
// import layout
import Layout from '../../components/layout';

// class based component
class NewDreamStory extends Component {
  // render
  render() {
    return (
      <Layout>
        <h1>New Dream Story!</h1>
      </Layout>
    );
  }
}
// export the component
export default NewDreamStory;


new_story 페이지가 완성되었습니다! 웹브라우저에 http://localhost:3000/dream_stories/new_story라고 입력하면 페이지가 보입니다. 그런데 예쁘게 꾸며져 있지 않습니다. 그건 CSS 스타일 링크가 new_story.js에는 없기 때문입니다. 이전 장에서 임시적으로 작업했던 것을 이번에 고쳐보겠습니다.


CSS 수정

앞서 임시로 CSS 스타일 링크를 index.js의 component에 직접 삽입했었습니다. 그래서 index 페이지에서는 스타일이 보이지만, new_story 페이지에서는 보이지 않는 것입니다. 따라서 해결 방법을 설명드리겠습니다. 방법은 CSS 스타일 링크를 HTML 도큐먼트의 <head>태그에 넣는 것입니다. 많은 웹페이지를 보면 대부분 <head>태그에 CSS 스타일 링크가 들어가 있습니다.

Next에서 이를 위해 조금은 편한 방법을 제공하고 있습니다. 바로 next/head 패키지를 이용하면 원하는 내용을 <head>태그에 삽입할 수가 있습니다. 그리고 layout.js는 모든 페이지에 import되기 때문에, 이곳에 삽입하면 좋습니다. 방법을 요약해 보면 다음과 같습니다.

① index.js에서 CSS 스타일 링크를 잘라냄
② CSS 스타일 링크를 layout.js 파일의 컴포넌트 안에 복사함
③ layout 컴포넌트 안에서 CSS 링크를 Next의 head로 감쌈

new_story.js의 내용은 바꿀 것이 없고 layout.js를 다음과 같이 수정해 이 방법을 적용합니다.

// import react
import React from 'react';
// import Container
import { Container } from 'semantic-ui-react';
// import <head> tag of next
import Head from 'next/head';
// import header.js
import Header from './header';
// import footer.js
import Footer from './footer';

// functional component
export default ( props ) => {
  return (
    <Container>
      <Head>
        <link
          rel="stylesheet"
          href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.3.1/semantic.min.css"
        />
      </Head>
      <Header />
      {props.children}
      <Footer />
    </Container>
  );
};


이렇게 한 후 http://localhost:3000/dream_stories/new_story를 브라우저에 입력하면 다음과 같이 헤더가 예쁘게 표현됩니다. 실제 CSS 링크 코드가 <head>태그에 삽입됐는지 브라우저에서 Ctrl+Shift+C를 눌러 Elements 탭에서 <head>태크를 펼쳐봅니다. 네 거기 삽입되어 있죠?
image.png


Form 꾸미기

페이지에 2개의 input을 받는 컴포넌트가 필요합니다. 하나는 DreamStory를 위한 좀 큰 input 컴포넌트가 필요하고, 다른 하나는 해당 story를 다운로드할 때 최소한의 금액을 Ether로 입력하는 input 컴포넌트가 필요합니다.

story를 위한 컴포넌트는 semantic-ui-react의 TextArea를 사용하고, 최소 다운로드 금액도 일반 input이 아니라 semantic-ui-react의 Input을 사용합니다. 다음 코드가 이것을 구현한 것입니다. 코드가 좀 길고 복잡해 보이지만, 하나하나 의미를 따져보면 어렵지 않게 그 의미를 알 수 있습니다.

한 가지 설명할 부분은 TextArea나 Input이나 입력 내용이 변경되면 변경된 내용을 저장해야 합니다. 그래서 여기서는 state라는 변수를 써서 해당 내용을 저장합니다. 이때 각 컴포넌트의 event 기능을 이용합니다.

// import react
import React, { Component } from 'react';
// import Form, Button from semantic-ui-react
import { Form, Button, Input, TextArea } from 'semantic-ui-react';
// import layout
import Layout from '../../components/layout';

// class based component
class NewDreamStory extends Component {
  // render
  render() {
    return (
      <Layout>
        <Form>
          <Form.Group>
            <Form.Field width={12}>
              <label>Creat a Dream Story</label>
              <Input
                placeholder='Title'
              />
            </Form.Field>
            <Form.Field width={4}>
              <label>Minimum Download Price</label>
              <Input
                label="ether"
                labelPosition="right"
                placeholder='0.001'
              />
            </Form.Field>
          </Form.Group>
          <Form.Group>
            <Form.Field width={12}>
              <TextArea
                label='Creat a Dream Story'
                placeholder='Tell us about your dream story'
                style={{ minHeight: 300 }}
              />
            </Form.Field>
          </Form.Group>
          <Button primary>Create!</Button>
        </Form>
      </Layout>
    );
  }
}

// export the component
export default NewDreamStory;


이번에도 그럴싸한 페이지가 출력됩니다. 화면 꾸미는 것은 semantic-ui-react를 쓰면 편리하긴 하나 배치라던지, 입력창 크기라던지, 그 안에 문자 배열 위치라던지 이런 것은 전문가가 아니라 이것저것 찾아야 해서 시간이 걸립니다. 찾다 찾다 안 되는 것은 현재 그냥 둘 수밖에 없네요. 배치가 중요한게 아니니까요.
image.png


Event Handler

사용자가 "Create" 버튼을 누르면 작성한 Story와 입력한 Minimum Download Price를 가지고 DreamStory 컨트랙트를 생성, 배포해야 합니다. 이를 위해서 필요한 것을 나열하면 다음과 같습니다.

① TextArea, Input에 입력된 내용 저장
② web3를 통한 DreamFactory 데이터 접근
③ 사용자가 "Create" 버튼을 누르면 입력된 내용으로 DreamStory 생성, 배포
④ 생성, 배포가 잘못되면 에러 메시지 표시
⑤ 테스트넷에서 트랜잭션이 처리 완료될 때까지 대기 표시


상태변수

각각에 대해서 살펴보겠습니다. ①번의 입력 영역에 입력된 내용을 저장하기 위해서는 상태변수가 필요합니다. React에서는 이 변수로 state라는 이름이 지정되어 있습니다. state 안에 상태 저장이 필요한 변수를 입력합니다. 다음 코드에서는 입력 내용뿐 아니라, ④번의 에러 메지시를 처리하기 위한 상태와 ⑤번 트랜잭션 대기 표시를 위한 상태도 포함되어 있습니다.

(생략)
// class based component
class NewDreamStory extends Component {
  // state for form inputs and error message
  state = {
    title: '',
    story: '',
    min_down_price: '',
    error_msg: '',
    loading: false
  };
(생략)


그 다음 입력창에서 이 상태변수를 이용하여 내용이 입력될 때마다 내용을 저장합니다. 다음 코드를 보면 Input 태그에 두 가지 내용이 추가되었습니다. 하나는 value특성으로 상태변수에서 값을 읽어서 입력창에 전달하는 것입니다. 다른 하나는 onChange로 내용이 변경되면 호출되는 함수를 설정합니다. 여기서는 변경된 내용이 상태변수에 저장되도록 했습니다.

(생략)
<Input
  placeholder='Title'
  value={ this.state.title }
  onChange={ event => this.setState( { title: event.target.value } ) }
/>
(생략)


다른 입력 Form도 이와 같이 처리해 줍니다. 코드는 생략합니다. 코드 전문은 글 하단에 올리겠습니다.


DreamStory 생성 및 배포

이 부분은 여러 번 해봤습니다. 이미 web3.js를 만들었고 또 dream_factory.js를 만들어서 언제든지 테스트넷에 배포된 DreamFactory 컨트랙트에 접근하여 정보를 읽을 수 있게 하였습니다. 따라서 다음과 같이 필요한 파일 import와 web3를 통한 데이터 접근을 입력합니다. 코드를 입력할 위치를 생각해 봐야 합니다. 이벤트가 발생했을 때, 즉 author가 내용을 입력하고 "Create" 버튼을 눌렀을 때, 이것이 처리되어야 합니다. 이를 위해 이벤트 핸들러 함수를 만들어야 합니다. 여기서는 그 함수 이름으로 OnCreate를 사용합니다.

(생략)
// import DreamFactory
import dream_factory from '../../ethereum/dream_factory';
// import web3
import web3 from '../../ethereum/web3';

// class based component
class NewDreamStory extends Component {
  // state for form inputs and error message
  state = {
    title: '',
    story: '',
    min_down_price: '',
    error_msg: '',
    loading: false
  };

  // event handler for create button
  onCreate = async () => {
    // block default submitting the form
    event.preventDefault();
    // catch any error while executing the following
    try {
      // get all accounts and use the accounts[0] to create a DreamStory
      const accounts= await web3.eth.getAccounts();
      // convert minimum download price to wei
      const min_down_price_wei= web3.utils.toWei( this.state.min_down_price, 'ether' );
      // call createDreamStory of DreamFactory
      // use metamask's functinality to estimate the gas limit
      await dream_factory.methods
      .createDreamStory( min_down_price_wei, this.state.title, this.state.story )
      .send({
        from: accounts[0]
      });
    } catch (error) {
    }
  };

  • try-catch문: author가 입력창에 내용을 잘 입력했는지, web3는 제대로 처리되는지 일일히 확인하지 앟고, try-catch문으로 오류 검출
  • event.preventDefault(): 입력창에 내용을 submit하면(여기서는 Create 버튼을 누르면) form 태그에 의해 내용이 서버로 전달되도록 되어 있는데 이것을 방지하고, 스마트 컨트랙트가 처리하게 함
  • Minimum Download Price는 ether로 입력되는데, DreamFactory 컨트랙트는 wei로 받아야 함. 스마트 컨트랙트애서 소수점 처리하는 변수 사용보다 정수 변수 사용이 용이하기 때문. 또 사용자는 wei보다는 ether로 입력하는 것이 용이함. 따라서 ether로 입력된 것을 wei로 변경해줌
  • DreamFactory의 createDreamStory 함수를 호출. 이 때 Minimum Download Price와 Story title, Story를 인수로 전달함
  • 트랜잭션을 전송하기 위해서는 Metamask가 설치되고, Metamask에 암호를 입력해서 로그인 해야 함

제일 마지막 문장에 대해서 다시 한번 설명드리겠습니다. web3.js 만들 때 Next는 서버 사이드 렌더링이기 때문에 브라우저에서 제공하는 Metamask를 이용할 수 없다고 했습니다. 그리고 브라우저에서는 web3 버전에 따른 문제를 없애고자 Metamask에서 제공하는 provider를 사용하기로 했구요.

web3.js를 다음 코드처럼 만들었습니다. 그래서 위에서 트랜잭션을 send할 때 Metamask의 기능을 이용하게 되며, 특별히 gas를 입력하지 않아도 Metamask에서 알아서 처리해 줍니다.

// handle client side rendering and metamask user
if( typeof window !== 'undefined' && window.web3 !== 'undefined' ){
  // we are in the browser and metamask is running,
  // so use the provider injected by metamask without considering the web3 version
  web3= new Web3( window.web3.currentProvider );
}
// handle server-side rendering and non-metamask user
// the window variable is undefined
else{
  // we are on the server or the user is not running metamask
  // make own provider through infura
  const provider= new Web3.providers.HttpProvider(
    // put your infura api key for rinkeby
    'https://rinkeby.infura.io/v3/your_api_key'
  );
  web3= new Web3( provider );
}


마지막으로 Form에 이벤트 핸들러를 설정합니다. 뒤의 에러 메지시 관리하는 부분은 다음 내용을 참고해 주세요.

(생략) 
<Layout>
    <Form onSubmit={this.onCreate} error={!!this.state.error_msg}>
       <Form.Group>
(생략)


에러 처리 및 트랜잭션 Pending 표시

  (생략)
  // event handler for create button
  onCreate = async () => {
    // block default submitting the form
    event.preventDefault();
    // set button loading and clear error message
    this.setState({ loading: true, error_msg: '' });
    // catch any error while executing the following
    try {
      (생략)
    } catch (error) {
      this.setState( { error_msg: error.message } );
    }
    // clear loading
    this.setState({ loading: false });
  };
  (생략)

먼저 이벤트 핸들러가 실행되자마자 트랜잭션 대기 표시를 위한 loading 상태를 true로 만들고, error_msg를 클리어합니다. 트랜잭션 처리 시에 에러가 발생하면 그 에러를 받아서 상태변수에 저장합니다. 그리고 트랜젹션이 성공했던 실패했던 트랜잭션 처리가 종료되면 대기 표시를 클리어합니다.

다음과 같이 에러 메시지를 표시하기 위한 <Message>태그에 상태변수인 error_msg를 설정하고, 트랜잭션 처리 대기를 위해 <Button>태크의 loading 특성에 상태 변수 값을 설정합니다.

 <Message error header="Failed!" content={ this.state.error_msg } />
 <Button loading={this.state.loading} primary>Create!</Button>


에러 메시지를 적절하게 표시하고 없애고 하는 부분이 있습니다. 다음 코드인데요. Form에서 상태변수인 error_msg의 값이 없다면 error는 false가 되어, 즉 에러가 없는 상황으로 처리하게 됩니다. 반면에 error_msg에 값이 있다면, !!this.state.error_msg에 의해 !(false) -> true 가되어 에러 상황으로 처리합니다.

  <Form onSubmit={this.onCreate} error={!!this.state.error_msg}>


new_story.js 코드

new_story.js 코드 전문은 다음과 같습니다. 코드와 앞서 설명한 내용을 보면 이해가 될 것입니다.

// import react
import React, { Component } from 'react';
// import Form, Button from semantic-ui-react
import { Form, Button, Input, TextArea, Message } from 'semantic-ui-react';
// import layout
import Layout from '../../components/layout';
// import DreamFactory
import dream_factory from '../../ethereum/dream_factory';
// import web3
import web3 from '../../ethereum/web3';

// class based component
class NewDreamStory extends Component {
  // state for form inputs
  state = {
    title: '',
    story: '',
    min_down_price: '',
    error_msg: '',
    loading: false
  };

  // event handler for create button
  onCreate = async () => {
    // block default submitting the form
    event.preventDefault();
    // set button loading and clear error message
    this.setState({ loading: true, error_msg: '' });
    // catch any error while executing the following
    try {
      // get all accounts and use the accounts[0] to create a DreamStory
      const accounts= await web3.eth.getAccounts();
      // convert minimum download price to wei
      const min_down_price_wei= web3.utils.toWei( this.state.min_down_price, 'ether' );
      // call createDreamStory of DreamFactory
      // use metamask's functinality to estimate the gas limit
      await dream_factory.methods
      .createDreamStory( min_down_price_wei, this.state.title, this.state.story )
      .send({
        from: accounts[0]
      });
    } catch (error) {
      this.setState( { error_msg: error.message } );
    }
    // clear loading
    this.setState({ loading: false });
  };

  // render
  render() {
    return (
      <Layout>
        <Form onSubmit={this.onCreate} error={!!this.state.error_msg}>
          <Form.Group>
            <Form.Field width={12}>
              <label>Creat a Dream Story</label>
              <Input
                placeholder='Title'
                value={ this.state.title }
                onChange={ event => this.setState( { title: event.target.value } ) }
              />
            </Form.Field>
            <Form.Field width={4}>
              <label>Minimum Download Price</label>
              <Input
                label="ether"
                labelPosition="right"
                placeholder='0.001'
                value={ this.state.min_down_price }
                onChange={ event => this.setState( { min_down_price: event.target.value } ) }
              />
            </Form.Field>
          </Form.Group>
          <Form.Group>
            <Form.Field width={12}>
              <TextArea
                label='Creat a Dream Story'
                placeholder='Tell us about your dream story'
                style={{ minHeight: 300 }}
                value={ this.state.story }
                onChange={ event => this.setState( { story: event.target.value } ) }
              />
            </Form.Field>
          </Form.Group>
          <Message error header="Failed!" content={ this.state.error_msg } />
          <Button loading={this.state.loading} primary>Create!</Button>
        </Form>
      </Layout>
    );
  }
}

// export the component
export default NewDreamStory;


<Input
  placeholder='Title'
  value={ this.state.title }
  onChange={ event => this.setState( { title: event.target.value } ) }
/>


샘플 DreamStory 생성

한번 첫 번째 스토리를 작성해서 Create 버튼을 눌러 보겠습니다. 제대로 동작하는 것을 볼 수 있습니다.
image.png


Rinkeby 테스트넷에 접속하면 다음과 같이 트랙잭션 처리가 완료되어 있습니다.
image.png



이것으로 new_story 페이지를 완료했습니다. 사실 create 버튼을 누르고 나면 다시 index 페이지로 이동하는게 좋습니다. 다음엔 이렇게 이동할 수 있게 라우팅하는 방법을 살펴보겠습니다.



오늘의 실습: 자, 자신의 첫번째 Dream Story를 써보세요!