Group Study (2020-2021)/ReactJS + Spring Boot ToyProject

[웹 3팀] 1. Create, Read, Update 기능 구현해보기

hyeowl 2020. 12. 25. 13:50

리액트 스프링 연동도 됐고, DB도 구축이 되었다면 이제 본격적으로 게시판을 만들어보자!

(참고로 우리 팀은 AWS를 이용한 mySQL을 사용했다)

 

congsong.tistory.com/15?category=749196

백엔드에 관련해서는  저 곳에서 도움을 정말 많이 받았다. 거의 코드를 가져다 쓰는 식으로 만들어서ㅋㅋㅋ 

다만 저기서는 리액트를 사용하지 않았기에 여기서는 Axios 및 fetch를 이용해서

어떻게 프론트엔드를 만들었는지를 작성할 예정이다!

 

1. Create

먼저 리액트 프로젝트 src 폴더 내부에 page 폴더를 만든다.

이번 프로젝트에서 화면을 구성하는 컴포넌트들은 다 저기다가 만들어놨다.

첫번째로 만들어 볼 페이지는 글쓰기 페이지!

page 폴더에 write.js 파일을 만들고 다음과 같이 작성한다.

import React from 'react';

function Write() {

    return (
        <div className='Write'>
            <form action="/api/write" method="post">
                <div>
                	<input type='text' id='title_txt' name='title' placeholder='제목'/> 
                </div>
                <div>
                	<input type='url' id='link_txt' name='url' placeholder='링크'/> 
                </div>
                <div>
                	<textarea id='ex_txt' name='content' placeholder='설문내용에 대해 설명해주세요'></textarea>    
                </div>
                <div>
                    시작일자&nbsp;&nbsp;
                        <input type="date" name="startDate" id='date'/>
                    	&nbsp;&nbsp;
                        
                    마감일자&nbsp;&nbsp;
                        <input type="date" name="endDate" id='date'/>    
                </div>
                <div id="submit_btn">
                    <button type="submit">저장</button>&nbsp;&nbsp;
                    <button>취소</button>
                </div>
            </form>
        </div>
    );
}

export default Write;

우리팀은 스프링에서 insert 용 컨트롤러를 만들 때 value 값을 "/api/write"로 했기에

<form> 태그 속성을 보면 action="/api/write", method="post"로 되어있다. 

create는 딱히 까다로운 게 없었다. 폼에다가 데이터를 입력하고 submit 버튼을 누르면

스프링 프로젝트에서 구현한 그대로 데이터베이스에 저장이 되었다. 

write.js / 팀원 분이 만들어주신 귀여운 디자인!!

 

2. Read

읽기 기능부터는 구현할 게 많아졌다.

먼저 메인 페이지에 들어왔을 때 저장한 데이터들이 보여져야 하고(말 그대로 게시판이 보여야 함)

해당 데이터를 클릭했을 때는 세부 데이터가 보이도록 페이지 이동을 해야 했다. 

그리고 여기서부터 서버에서 데이터를 가져오기 위해 Axios를 썼다.

 

Main 컴포넌트 및 게시판 리스트를 보여줄 List 컴포넌트를 만들기 전 App.js 파일부터 수정하자!

import React, { Component } from 'react';
import Main from './page/main';
import Write from './page/write';
import './App.css';
import { Route } from 'react-router-dom';

class App extends Component {
 
  render() {
    return(
      <div>
          <Route exact path="/" component={Main} />
          <Route path="/write" component={Write} />
      </div>
    )
  }
}

export default App;

아직 Main 컴포넌트는 만들지 않은 상태이다.

리액트에서는 페이지 이동을 구현하기 위해서 router를 사용한다.

npm install react-router-dom --save

or

yarn add react-router-dom

npm이나 yarn으로 미리 'react-router-dom' 라이브러리를 받아놓자.

이제 page 폴더 내에 main.jslist.js 파일을 만들어준다.

import React, { Component } from 'react';
import List from './list';

class main extends Component {

    render() {
        return (
            <div>
                <List />
            </div>
        );
    }
}

export default main;
import React, { useState, useEffect } from 'react';
import ListItem from './listItem';
import './list.css'

function useFetch(url) {

    const [data, setData] = useState([]);
    
    async function fetchUrl() {
        const response = await fetch(url);
        const json = await response.json();
        
        setData(json);
    }
    
    useEffect(() => {
        fetchUrl();
    }, []);
    return data;
}


function List() {

    const data = useFetch("/api/list");
    
    return (
        <main className="list-template">     
            <div className="list-title">
                폼 게시판
            </div>
            <section className="head-wrapper">
                <span>번호</span>
                <span className="title-column">제목</span>
                <span>작성자</span>
                <span>작성일</span>
                <span>마감일</span>
            </section>
            <section className="list-wrapper">
                {data.map(
                    ({board_id, title, start_date, end_date}) => (
                        <ListItem
                            board_id={board_id}
                            title={title}
                            start_date={start_date}
                            end_date={end_date}
                            key={board_id}
                        />
                    )
                )}
            </section>
        </main>
    );
}

export default List;

main.js는 단순히 list.js를 감싸주는 용도이고, 데이터를 실제로 출력해주는 것은 list.js 파일이다.

useFetch를 먼저 보면, javascript에 내장된 fetch 함수를 사용해서 fetchUrl 함수를 만들었다. 

특정 url을 넣어 response로 받은 값을 json으로 변환하여 state에 저장하도록 하였다.

그리고 useEffect 훅을 사용해서 컴포넌트가 mount 되자마자 fetchUrl 함수를 사용하게끔 한다.

 

List 컴포넌트는 이 useFetch 함수를 사용해서 받은 데이터를 ListItem 컴포넌트에 전달해준다.

ListItem 컴포넌트는 게시판 리스트에 올라올 각 데이터를 보여주기 위해 구현한 것이다.

List 컴포넌트가 게시판 보드라면, ListItem은  그 보드에 붙여있는 개별적인 데이터들이라고 보면 된다.

import React from 'react';
import { Link } from 'react-router-dom';
import './ListItem.css'

function ListItem({ board_id, title, start_date, end_date }) {

    return (
        <Link to={{
            pathname:"/detail",
            search:`?board_id=${board_id}`
        }} style={{ textDecoration: 'none', color: 'black'}}>
            <div className="list-item">
                <div className="id">{board_id}</div>
                <div className="column-title">{title}</div>
                <div className="member">작성자</div>
                <div className="start">{start_date}</div>
                <div className="end">{end_date}</div>
            </div>
        </Link>
    )
}

export default ListItem;

List 컴포넌트에서 전달해 준 데이터를 props로 받고 있다.

return 부분을 보면 'react-router-dom'에서 제공하는 <Link>로 감싸고 있는데,

게시판에서 해당 데이터를 클릭했을 때 상세 데이터를 보여주는 페이지로 이동하기 위한 것이다. 

메인페이지에 들어갔을 때 보이는 화면

 

이제 detail.js에서 Detail 컴포넌트로 구현해보자

import React, { Component } from 'react';
import Main from './page/main';
import Write from './page/write';
import Detail from './page/detail';
import './App.css';
import { Route } from 'react-router-dom';

class App extends Component {
 
  render() {
    return(
      <div>
          <Route exact path="/" component={Main} />
          <Route path="/write" component={Write} />
          <Route path="/detail" component={Detail} />
      </div>
    )
  }
}

export default App;

상세 페이지 구현을 위해 App.js에 detail.js를 import 해주고 page 폴더 내부에 detail.js 파일을 만든다.

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import qs from 'qs';
import './read.css';

function useFetch(url, id) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    
    function fetchUrl() {
        await axios.get(`${url}?board_id=${id}`).then(response => {
            setData(response.data);
        });
        setLoading(false);
    }
    useEffect(() => {
        if (id) {
            fetchUrl();
        } else {
            setData(null);
            setLoading(false);
        }
    }, []);
    return [data, loading];
}

const Detail = ({ location, history }) => {
    
    const query = qs.parse(location.search, {
        ignoreQueryPrefix: true
    });
    console.log(query);

    const [data, loading] = useFetch("/api/detail", query.board_id);
    
    if (loading) {
        return (
            <div>loading</div>
        )
    } else {
        return (
            <div className='Read'>
                <div className="list-title">
                폼 게시판
                </div>
                <div className='read_title'>
                    {data.title}
                </div>
                <table> 
                <tbody>
                    <tr align="center" className='table_info'> 
                    <td width="15%">작성일</td> 
                    <td width="20%">{data.startDate}</td> 
                    <td width="15%">마감일</td> 
                    <td width="20%">{data.endDate}</td>
                    <td width="15%">작성자</td> 
                    <td width="15%">송이송이</td> 
                    </tr>
                    <tr align="center" className='table_info'>
                    <td width="15%">설문링크</td>
                    <td colspan="5">{data.url}</td>
                    </tr>
                    <tr height="500px">
                    <td colspan="6">{data.content}</td>
                    </tr>
                </tbody> 
                </table>
                <div className='ud_btn'>
                <Link to={{
                        pathname:"/write",
                        search:`?board_id=${query.board_id}`
                    }}>
                    <button className='btn1'>수정</button>
                </Link>
                <button className='btn1'>삭제</button>
                </div>
                <div className='return_btn'>
                <button>목록으로 돌아가기</button>
                </div>
            </div>
        )
    }
}
export default Detail;

Detail 컴포넌트를 구현하기 위해서 'axios''qs'를 이용했다. 

list.js에서는 axios 라이브러리 인스톨하는 게 귀찮다고.. fetch를 썼다. (대충 다양한 경험으로 포장)

npm install axios --save

or

yarn add axios
npm install qs --save

or 

yarn add qs

 

 

라우트 컴포넌트들은 props로 전달되는 location 객체의 search에서 현재 컴포넌트의 주소값을 쓸 수 있는데 

이 search는 문자열 형태라 객체 형태로 변환을 해줘야 한다. 'qs'는 변환 작업을 해주는 라이브러리다.

Detail 컴포넌트의 주소에는 현재 보여줄 데이터의 id값이 담겨져 있다.

이 id를 서버에 보내면 해당 데이터만 가져올 수 있다!

그리하여 완성된 detail.js 페이지

 

3. Update

대망의 수정기능!! 자잘한 버그들이 튀어나와서 구현하는 데 가장 오래 걸린 부분이기도 하다 ㅎㅎ

특별히 파일을 새로 만들진 않았고 기존에 만든 write.js 파일에 추가하는 방식으로 진행했다.

바로 위의 Detail 컴포넌트를 보면, 하단의 수정 버튼이 <Link> 태그 처리가 되어 있는데

Write 컴포넌트로 보내게 하였다. Create 때와 차이는 해당 데이터 id를 url에 담아 전달하는 것이다.

import React, { useRef, useState, useEffect } from 'react';
import axios from 'axios';
import qs from 'qs';

function Write({ location }) {

    const query = qs.parse(location.search, {
        ignoreQueryPrefix: true
    });

    const [data, setData] = useState(null);

    let inputRef = useRef([]);
    inputRef.current = [0,0,0,0,0].map(
        (ref, index) => inputRef.current[index] = React.createRef()
    )
    
    async function fetchUrl(url, id) {
        
        await axios.get(`${url}?board_id=${id}`).then(response => {
            setData(response.data);
        });
    }

    useEffect(() => {
        if (query.board_id) {
            fetchUrl("/api/detail", query.board_id)
        } else {
            setData(null);        
            inputRef.current.map(item => 
                item.current.value = '')
        }
        return () => {
            setData(null);
        }
    }, [location.search]);
    

    const handleChange = (e) => {
        setData({[e.target.name]: e.target.value});
    }

    return (
        <div className='Write'>
            <form action="/api/write" method="post">
                    { 
                        data && location.search && <input type='hidden' name="boardId" value={data.boardId} /> 
                    }
                <div>
                    {
                        data && location.search ? 
                        <input type='text' id='title_txt' name='title' value={data.title} onChange={handleChange} /> :
                        <input ref={inputRef.current[0]} type='text' id='title_txt' name='title' placeholder='제목'/> 
                    }
                    
                </div>
                <div>
                    {
                        data && location.search ? 
                        <input type='url' id='link_txt' name='url' value={data.url} onChange={handleChange} /> :
                        <input ref={inputRef.current[1]} type='url' id='link_txt' name='url' placeholder='링크'/> 
                    }
                </div>
                <div>
                    {
                        data && location.search ? 
                        <textarea id='ex_txt' name='content' value={data.content} onChange={handleChange}></textarea> :
                        <textarea ref={inputRef.current[2]} id='ex_txt' name='content' placeholder='설문내용에 대해 설명해주세요'></textarea>    
                    }
                </div>
                <div>
                    시작일자&nbsp;&nbsp;
                    {
                        data && location.search ? 
                        <input type="date" name="startDate" id='date' value={data.startDate} onChange={handleChange}/> :
                        <input ref={inputRef.current[3]} type="date" name="startDate" id='date'/>
                    }
                    &nbsp;&nbsp;
                    마감일자&nbsp;&nbsp;
                    {
                        data && location.search ? 
                        <input type="date" name="endDate" id='date' value={data.endDate} onChange={handleChange} /> :
                        <input ref={inputRef.current[4]} type="date" name="endDate" id='date'/>    
                    }
                </div>
                <div id="submit_btn">
                    <button type="submit">저장</button>&nbsp;&nbsp;
                    <button>취소</button>
                </div>
            </form>
        </div>
    );
}

export default Write;

엄청 난잡해진 코드..

여튼 여기서 데이터 id를 받아온 이유는 Write 페이지로 넘어갔을 때

기존에 저장해 놓은 데이터들을 불러와서 그걸 바탕으로 수정하도록 만들기 위해서였다.

Create 때와 달리 미리 데이터가 들어가 있다

이걸 위해서는 Detail 컴포넌트에서 사용한 스프링 컨트롤러를 그대로 썼고(지금 생각해보면 따로 fetchUrl.js 만들어서 가져다 쓸 걸 그랬다.)

Create와 Update를 구분하기 위해서 useEffect 내에서 분기처리 해줬다.

url 쿼리에서 board_id가 있으면 update 페이지인 거고, 없으면 create 페이지이다.

update 일 때 fetchUrl을 사용해서 state에 데이터를 저장한다.

return에서는 이 state 값과 쿼리값을 바탕으로 update 페이지를 그릴지 create 페이지를 결정한다.

data && location.search ? 
<input type='text' id='title_txt' name='title' value={data.title} onChange={handleChange} /> :
<input ref={inputRef.current[0]} type='text' id='title_txt' name='title' placeholder='제목'/> 

이런 식으로. 특이했던 건 <input> 태그 안에 value 값을 미리 지정해 놓으면 글자를 수정하는 게 안 되는 거였다.

그래서 onChange 속성을 사용해서 수정을 했는데, 이러면 글자를 쓸 때마다 랜더링이 일어나는 거니 좀 아쉬웠다.

 

그리고 이번 프로젝트에서 제일 고생했던 setState 비동기 문제..!

페이지 배너에 있는 '설문조사 올리기' 버튼을 클릭하면 무조건 create 형식으로 write.js가 불러져 와야 했다.

어느 페이지에서든 저 버튼을 누르면 <input> value가 빈 값인 채로 잘 나왔는데

이상하게 update용 write.js 페이지에서 저 버튼을 누르면, 방금 페이지에 들어가 있던 데이터가 그대로 나왔다!!

그러니까 create용 write.js 페이지가 나와야 하는데, update용 write.js 페이지가 나오는 것이었다.

(수정 페이지는 detail 페이지에서 수정 버튼을 눌렀을 때만 나와야 함)

 

useEffect(() => {
        if (query.board_id) {
            fetchUrl("/api/detail", query.board_id)
        } else {
            setData(null);        
            inputRef.current.map(item => 
                item.current.value = '')
        }
        return () => {
            setData(null);
        }
    }, [location.search]);

Write 컴포넌트의 useEffect에서 create용 페이지를 불러올 때unmount될 때 state값을 분명 null 처리를 해줬는데도

데이터가 그대로 화면에 출력이 되는 게 이상했다.

 

코어 멤버분께 물어봐서 이게 setState 비동기 문제인 걸 알게 됐다. 

state값이 update 되기도 전에 화면이 그려진 것이다. 

보통은 이럴 때 이 state 값을 useEffect의 dependency array에 넣어 해결하는 것 같았는데

지금 상황에서는 state 값을 바탕으로 onChange도 적용하고 있어서 무한 랜더링이 일어날 것 같았다.

 

그래서 이것저것 시도해 본 결과 적용하기로 한 것이 useRef hook이었다. 

useState, useRef 모두 state 관리를 위한 훅 정도로만 알고 있었다.

useRef는 state 값 변화시 재랜더링이 일어나지 않는다는 차이정도?

그런데 신기하게도 콘솔에 찍어보니 useRef는 state 변화가 바로 일어났다. 

import React, { useRef, useState, useEffect } from 'react';
import axios from 'axios';
import qs from 'qs';

function Write({ location }) {

  	...

    let inputRef = useRef([]);
    inputRef.current = [0,0,0,0,0].map(
        (ref, index) => inputRef.current[index] = React.createRef()
    )
    
   ...

    useEffect(() => {
        if (query.board_id) {
            fetchUrl("/api/detail", query.board_id)
        } else {
            setData(null);        
            inputRef.current.map(item => 
                item.current.value = '')
        }
        return () => {
            setData(null);
        }
    }, [location.search]);
    
	...
    
    return (
        <div className='Write'>
            <form action="/api/write" method="post">
                    { 
                        data && location.search && <input type='hidden' name="boardId" value={data.boardId} /> 
                    }
                <div>
                    {
                        data && location.search ? 
                        <input type='text' id='title_txt' name='title' value={data.title} onChange={handleChange} /> :
                        <input ref={inputRef.current[0]} type='text' id='title_txt' name='title' placeholder='제목'/> 
                    }
                    
               ...

그래서 useRef로 inputRef 배열을 만들고 create의 <input>에서는 각각 배열의 값들을 참조하게끔 하였다.

그리고 useEffect에서 create용 페이지 일 때는 inputRef 값들을 모두 빈 문자열 처리를 해 주었더니

update 용 페이지에서 설문조사 올리기 버튼을 올려도 데이터가 모두 지워져서 나올 수 있었다!

 

여기까지 Create, Read, Update 기능 구현이 완료됐는데

실은 완전히 이해하면서 했다기 보다는 이것저것 끼워맞춰 보면서 대충 리액트 스프링 연동의 감만 잡은 상태라

추가적인 공부가 정말 많이 필요할 것 같다.

특히 async await를 이용한 비동기 처리나, 위에서 문제가 된 setState 비동기 처리!! 

스프링은 정말 따라만 한 거라 그냥 추가적으로 게시판을 다시 만들어봐야 할 수준이다

방학에 열심히 달리기로..