티스토리 뷰

# 시작

지난 포스팅에 게시판의 BE 설정을 했습니다. 이번 포스팅에서는 FE 설정을 진행합니다.

개인 공부를 기록하는 게 주목적이다 보니 기본적인 부분들의 설명은 생략했습니다.


# 준비

위와 같이 파일들을 생성합니다. 각 파일의 역할은 다음과 같습니다.

  • index: 게시판 목록 최상위 컴포넌트
  • Board: 게시판 목록의 각 로우를 생성하는 컴포넌트
  • BoardList: 게시판 목록을 생성하는 컴포넌트
  • BoardDetail: 게시글 상세보기 컴포넌트
  • BoardWriter: 게시글을 등록, 수정하는 컴포넌트
  • Queries: 게시판과 관련된 GraphQL 쿼리를 관리하는 컴포넌트
  • Type: TypeScript의 interface 및 type을 관리하는 컴포넌트

먼저 Route설정을 진행합니다.

RouteComponent파일을 다음과 같이 수정합니다.

import React from 'react'
import { Route } from 'react-router-dom';

import Home from './Home';
import Sample from '../sample';

import Board from '../board/index';
import BoardWriter from '../board/BoardWriter';
import BoardDetail from '../board/BoardDetail';

const RouteComponent = () => {
  return (
    <div id="wrapper">
      <Route exact={true} path="/" component={Home} />
      <Route path="/Sample" component={Sample} />

	  // (1)
      <Route path="/Board/:pageIndex" component={Board} />
      <Route path="/BoardWriter/:id" component={BoardWriter} />
      <Route path="/BoardDetail/:pageIndex/:id" component={BoardDetail} />

    </div>
  )
}

export default RouteComponent;

@(1)

:pageIndex는 파라미터입니다. 예를 들면 `/Board/2`로 라우팅을 했을 때 라우트 된 컴포넌트에서 `match.params.pageIndex`로 2라는 파라미터 값을 가져올 수 있습니다.


Sidebar파일도 다음과 같이 수정합니다.

import React from 'react';
import { NavLink } from 'react-router-dom';

const Sidebar = () => {
  return (
    <section id="sidebar">
      <div className="inner">
        <nav>
          <ul>
            <li><NavLink exact to="/" activeClassName="active">Home</NavLink></li>
            <li><NavLink to="/Board/0" activeClassName="active">Board</NavLink></li>
            <li><NavLink to="/Sample" activeClassName="active">Sample</NavLink></li>
          </ul>
        </nav>
      </div>
    </section>
  )
}

export default Sidebar;

# Front End

다음 파일들을 순서대로 작성하겠습니다.

 

Type.ts

// 페이지 이동시 사용할 Reducer의 타입 정의입니다.
export type TChangePageAction = { type: 'INCREMENT', length: number } | { type: 'DECREMENT', length: number };
export interface IPageIndexState {
  index: number;
}

export interface IBoardProps {
  pageIndex: string
}

export interface IBoardDetailProps {
  id: string
  pageIndex: string
}

export interface IBoardWriterProps {
  id: string
}

export interface IBoard {
  id: number
  title: string
  content: string
  visitCount: number
  postDate: string
  pageIndex: number
}

export interface IBoardList {
  boardList: [IBoard]
  pageIndex: number
}

export interface IPutBoard {
  [key: number]: IPutBoard
  id: number
  title: string
  content: string
  isPublic: boolean
}

// Board 컴포넌트의 td style에 적용할 타입 정의입니다.
export interface ITd {
  width: string,
}

Queries.tsx

import gql from 'graphql-tag';

export default {
  // 게시글 목록을 가져옵니다. 페이지 번호를 인자로 넘깁니다.
  GET_BOARD_LIST: gql`
    query GetBoardList($index: Int!) {
      getBoardList(index: $index) {
        id 
        title 
        visitCount 
        postDate 
      }
    }
  `,
  // 게시글 번호를 인자로 해서 게시글 하나의 데이터를 가져옵니다.
  GET_BOARD: gql`
    query GetBoard($id: Int!) {
      getBoard(id: $id) {
        id 
        title 
        content 
        isPublic 
        visitCount 
        postDate 
        updateDate 
      }
    }
  `,
  // 게시글을 신규 저장합니다. 제목, 내용, 공개 여부는 필수값입니다.
  POST_BOARD: gql`
    mutation PostBoard($title: String!, $content: String!, $isPublic: Boolean!) {
      postBoard(title: $title, content: $content, isPublic: $isPublic) {
        id 
        title 
        content 
        postDate
      }
    }
  `,
  // 게시글을 수정합니다. 제목, 내용, 공개 여부는 필수값입니다.
  PATCH_BOARD: gql`
    mutation PatchBoard($id: Int!, $title: String!, $content: String!, $isPublic: Boolean!) {
      patchBoard(id: $id, title: $title, content: $content, isPublic: $isPublic) {
        id 
        title 
        content 
        updateDate 
      }
    }
  `
};

게시판에서 사용할 쿼리들을 정의합니다.


Board.tsx

import React, { FunctionComponent } from 'react';
import { Link } from 'react-router-dom';
import { IBoard } from './Type';
import styled from 'styled-components';
import { ITd } from './Type';

const Td = styled.td`
  width: ${(props: ITd) => props.width || '100%'};
`;

const Board: FunctionComponent<IBoard> = ({ id, title, visitCount, postDate, pageIndex }) => {
  return (
    <tr>
      <Td width="15%">{id}</Td>
      <Td width="30%"><Link to={`/BoardDetail/${pageIndex}/${id}`}>{title}</Link></Td>
      <Td width="15%">{postDate}</Td>
      <Td width="15%">{visitCount}</Td>
    </tr>
  )
}

export default Board;

props로 단건 데이터를 받아서 로우를 구성하는 컴포넌트입니다.

 

@styled

Td 컴포넌트의 width속성 값을 파라미터로 받아 각각 다르게 적용합니다.


BoardList.tsx

import React, { useEffect, FunctionComponent } from 'react';
import { IBoardList, IBoard } from './Type';
import Board from './Board';

const BoardList: FunctionComponent<IBoardList> = ({ boardList, pageIndex }) => {

  const makeList = () => {
    return boardList.map((board: IBoard) => {
      return <Board
        key={board.id}
        id={board.id}
        title={board.title}
        content={board.content}
        visitCount={board.visitCount}
        postDate={board.postDate}
        pageIndex={pageIndex}
      />
    });
  }

  return (
    <div className="table-wrapper">
      <table>
        <thead>
          <tr>
            <th>번호</th>
            <th>제목</th>
            <th>등록일자</th>
            <th>조회수</th>
          </tr>
        </thead>
        <tbody>
          {makeList()}
        </tbody>
      </table>
    </div>
  );
}

export default BoardList;

props로 게시글 목록 데이터를 받아서 Board 컴포넌트의 배열로 변환하여 렌더링하는 컴포넌트입니다.


index.tsx

import React, { useReducer, FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { useQuery } from 'react-apollo-hooks';
import { Link } from 'react-router-dom';
import { IPageIndexState, IBoardProps, TChangePageAction } from './Type';
import BoardList from './BoardList';
import Queries from './Queries';

function PageReducer (state: IPageIndexState, action: TChangePageAction) {
  let { index } = state;
  const { type, length } = action;

  switch(type){
    case 'DECREMENT':
      //첫 페이지일 때
      if(index == 0) return state;
      return { index: --index };
    case 'INCREMENT':
      //마지막 페이지일 때
      if(length < 5) return state;
      return { index: ++index };
  }
}

const Index: FunctionComponent<RouteComponentProps<IBoardProps>> = ({ match }) => {
  
  let [ { index }, dispatch ] = useReducer(PageReducer, { index: Number(match.params.pageIndex) || 0 });
  const { data, loading, error } = useQuery(Queries.GET_BOARD_LIST,{ variables: { index }, fetchPolicy: "no-cache" });

  let length = 0;
  if( data ){
    length = data.getBoardList.length;
  }

  return (
    ( loading ) ? <span>불러오는 중...</span> :
    ( error ) ? <span>목록을 불러오는데 실패했습니다</span> :

    <section id="intro" className="wrapper style1 fullscreen">
      <div className="inner">
        <h2>글 목록</h2>
        <BoardList boardList={data.getBoardList} pageIndex={index} />
        <ul className="actions">
          <li><Link to="/BoardWriter/0" className="button">글쓰기</Link></li>
          <li><a href="#" onClick={() => dispatch({type: "DECREMENT", length})} className="button primary">이전</a></li>
          <li><a href="#" onClick={() => dispatch({type: "INCREMENT", length})} className="button primary">다음</a></li>
        </ul>
      </div>
    </section>
  );
}

export default Index;

@useQuery

페이지 번호를 인자로 넘겨서 해당하는 게시글 데이터들만 가져옵니다. 지난 포스팅에서 한 페이지의 개수를 5개로 정했기 때문에 최대 5개의 게시글만 가져오게 됩니다.

fetchPolicy는 메모리 캐시를 적용하지 않고 렌더링 할 때 서버에서 새로 목록을 가져오게 합니다. 만약 캐시를 적용하게 되면 신규 글 작성 시 목록으로 자동으로 이동할 때 메모리 캐시에서 데이터를 가져오기 때문에 방금 작성한 신규 글이 나타나지 않게 됩니다.

 

@useReducer

이전과 다음 버튼을 클릭할 때 페이지 번호를 변경하여 재 렌더링을 통해 페이지 목록을 갱신합니다.

 

여기서 사용한 훅 외에도 useState, useEffect 등 다양한 훅들이 있습니다. 궁금하신 분들은 여기를 참고하세요.

 

Using the Effect Hook – React

A JavaScript library for building user interfaces

ko.reactjs.org


BoardWriter.tsx

import React, { useState, FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { IPutBoard, IBoardWriterProps } from './Type';
import { useMutation } from 'react-apollo-hooks';
import Queries from './Queries';

const BoardPoster: FunctionComponent<RouteComponentProps<IBoardWriterProps>> = ({ match, history }) => {

  const id = Number(match.params.id);
  const postData: IPutBoard = {
    id: id || 0,
    title: '',
    content: '',
    isPublic: false
  };

  const [ state, setState ] = useState(postData);
  const [ postBoard] = useMutation(Queries.POST_BOARD);
  const [ patchBoard ] = useMutation(Queries.PATCH_BOARD);

  // 공개 여부 체크박스 이벤트
  const onCheckboxClick = (e: React.MouseEvent) => {
    setState({ 
      ...state,
      isPublic: !state.isPublic
    });
  };

  // 작성 폼 입력 시 데이터 변경 이벤트
  const onChangeState = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    setState({
      ...state,
      [e.target.id]: e.target.value
    });
  };
  
   // 신규 게시글 작성
  const onPostBoard = async () => {
    if( !validator() ) return false;

    const { title, content, isPublic } = state;
    try {
      const response = await postBoard({ variables: { title, content, isPublic } });
      afterSave(response.data.postBoard.id);
    } catch(error) {
      alert('에러가 발생했습니다. 에러 코드: 001');
      console.error(error.message);
    }
  };

  // 기존 게시글 수정
  const onPatchBoard = async () => {
    if( !validator() ) return false;

    const { id, title, content, isPublic } = state;
    try{
      const response = await patchBoard({ variables: { id, title, content, isPublic } });
      afterSave(response.data.patchBoard.id);
    } catch(error) {
      alert('에러가 발생했습니다. 에러 코드: 002');
      console.error(error.message);
    }
  };

  const validator = () => {
    const { title, content } = state;
    if( !title || !content ){
      alert('제목과 내용을 입력하세요');
      return false;
    }
    return true;
  };

  const afterSave = (id: number) => {
    if( id > 0 ){
      alert('저장되었습니다');
      history.push("/Board/0");
    } else {
      alert('저장되지 않았습니다. 다시 시도해주세요.');
    }
  }
  
  // 페이지 벗어날 때 확인
  const onLeavePage = () => {
    if( !confirm('작성 중이던 내용이 사라집니다. 정말 나가시겠습니까?') ){
      return false;
    }
    history.push("/Board/0");
  }

  return (
    <section id="intro" className="wrapper style1 fullscreen">
      <div className="inner">
        <section>
          <h3>글쓰기</h3>
          <form action="#">
            <div className="row gtr-uniform">
              <div className="col-6 col-12-xsmall">
                <input type="text" id="title" value={state.title} onChange={onChangeState} placeholder="Title" />
              </div>
              <div className="col-4 col-12-small">
                <input type="checkbox" checked={state.isPublic} onChange={()=>{}} />
                <label onClick={onCheckboxClick}>공개</label>
              </div>
              <div className="col-12">
                <textarea id="content" value={state.content} onChange={onChangeState} placeholder="Content"></textarea>
              </div>
            </div>
          </form>
          <ul className="actions">
            <li><a href="#" className="button" onClick={onLeavePage}>목록</a></li>
            // (1)
            <li><a href="#" className="button" onClick={ ( id == 0 ) ? onPostBoard : onPatchBoard}>작성</a></li>
          </ul>
        </section>
      </div>
    </section>
  )
}

export default BoardPoster;

@RouteComponentProps

react-router 패키지에서 제공하는 interface입니다. props에서 match, history 등을 사용하기 위해 선언합니다.

 

@useMutation

데이터를 변경하기 위해 사용하는 react-apollo-hook입니다. variables속성으로 인자를 넘겨줄 수 있습니다.

 

@(1)

신규 작성이라면 id값을 0으로 설정하고 수정이라면 수정할 게시글의 id값을 가져올 것이기 때문에 분기시켜줍니다.


BoardDetail.tsx

import React, { FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom'; 
import { useQuery } from 'react-apollo-hooks';
import { IBoard, IBoardDetailProps } from './Type';
import Query from './Queries';

const BoardDetail: FunctionComponent<RouteComponentProps<IBoardDetailProps>> = ({ match }) => {
  // (1)
  const { id, pageIndex } = match.params;
  const { data, loading, error } = useQuery(Query.GET_BOARD, { variables: { id } });

  if( loading ) return <span>불러오는 중...</span>;
  if( error ) return <span>데이터를 불러오는데 실패했습니다</span>;

  const boardData: IBoard = data.getBoard;

  return (
    <section id="intro" className="wrapper style1 fullscreen">
      <div className="inner">
        <section>
          <blockquote><h3>{boardData.title}</h3></blockquote>
          <form action="#">
            <div className="row gtr-uniform">
              <div className="col-12">
                <pre><code>{boardData.content}</code></pre>
              </div>
            </div>
          </form>
          <ul className="actions">
            <li><Link to={`/Board/${pageIndex}`} className="button">목록</Link></li>
            <li><Link to={`/BoardWriter/${id}`} className="button">수정</Link></li>
          </ul>
        </section>
      </div>
    </section>
  );
}

export default BoardDetail;

게시글 번호에 해당하는 데이터를 가져온 뒤 렌더링합니다.

 

@(1)

Route의 인자로 받은 게시글 번호와 게시글을 조회할 당시의 페이지 번호입니다.

 

목록 버튼을 클릭하면 조회할 당시의 페이지 번호에 맞는 목록으로 이동합니다.

수정 버튼을 클릭하면 해당 게시글 번호와 함께 수정 페이지로 이동합니다.


# 실행

Front End 구성도 완료되었습니다. 이제 제대로 작동하는지 확인합니다.

$ npm start

HOME메뉴와 SAMPLE메뉴 사이에 게시판 메뉴가 잘 나오는지 확인하고 클릭하여 제대로 Route 되는지 체크합니다.


화면이 정상적으로 이동되었다면 글쓰기 버튼을 클릭하여 새 글을 작성합니다. 이후 작성 버튼을 클릭하여 저장합니다.


목록으로 이동된 후 정상적으로 글이 갱신되었는지 확인합니다.


방금 등록된 글의 제목을 클릭하여 상세보기로 이동합니다.

이외에도 수정 및 페이지 이동이 정상적으로 작동하는 걸 확인할 수 있습니다.


# 마치며

간단한 게시판의 구현이 끝났습니다. 이후에는 댓글 기능을 추가하고, 관리자와 사용자를 구분하여 공개 여부에 따른 목록 호출과 글 수정, 삭제가 되도록 합니다.


# GitHub

https://github.com/eonnine/MyBlog.git

 

eonnine/MyBlog

Spring Boot, Graphql, PostgreSQL, React-Apollo, Parcel - eonnine/MyBlog

github.com

댓글