DB에 CRUD가 되는 게시판 만들기 - Frontend
지난 시간에 이어 이번에는 실제로 서버와 통신하며 게시글의 생성, 수정, 삭제가 되는 게시판 만들기를 위해 프론트엔드 쪽에서는 어떤 작업을 했는지 기록해보려고 한다.✍
본론으로 들어가기에 앞서, 지난번에 백엔드 작업을 기록하면서 사용 기술도 같이 적어두는걸 까먹었길래 여기에 적어본다.
백엔드는 Python, Fast API, MariaDB를 사용했고, 프론트엔드는 Axios, React Query, React, scss를 사용했다.
※ 게시판 만들기 백엔드 작업 기록 - https://hjinn0813.tistory.com/149
DB에 CRUD가 되는 게시판 만들기 - Backend
나중에 풀스택으로 성장하고 싶다고 말했지만, 이렇게 빨리 백엔드를 배우게 될 줄은 몰랐다.😂
hjinn0813.tistory.com
게시판 메인 페이지
전체 글 목록을 서버에서 가져와서 보여주는 페이지.
글이 생성/삭제된 상태가 해당 페이지의 전체 목록에서 반영되어야 한다.
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import '../style/Board.scss';
const getPosts = async () => {
const URL = 'http://127.0.0.1:8001/board';
const resp = await axios.get(URL);
return resp.data;
};
- 당연히 모든 코드 작성의 시작은 import 문이다.
useNavigate는 버튼이나 게시글 제목을 클릭했을 때, 해당 페이지로 이동시키기 위해서 가져왔다. - 그리고 본격적으로 코드 작성하기 전에 서버에서 글 목록을 불러오는 함수를 작성했다.
서버에서 데이터를 가져오는데 시간이 걸리니까 async/await를 사용해서 비동기적으로 요청을 처리한다.
export default function Board() {
const navigate = useNavigate();
const {
data: posts, error, isLoading,
} = useQuery({
queryKey: ['board'],
queryFn: () => getPosts(),
enabled: true,
refetchOnWindowFocus: false,
});
if (isLoading) return <div>Loading..⏳</div>;
if (error) return <div>Error: {error.message}</div>;
// 이하 생략
}
- useNavigate를 정의해주고 useQuery를 작성한다. 우선 변수들을 구조분해할당하여 가져오면서, data 변수는 다른 페이지에서도 사용해서 이름이 겹치는걸 피하기 위해 posts로 변경했다.
- 'board'라는 쿼리 키로 데이터를 가져오고 캐시를 관리한다.
queryFn으로 어떤 데이터를 가져올지 정의하는데, 여기서는 getPosts()의 데이터를 기다렸다가 posts 변수에 반환된 값을 넣어준다. - enabled는 useQuery가 자동으로 실행될지 여부를 결정하는 부분인데, true면 컴포넌트가 렌더링될 때 바로 실행된다.
- refetchOnWindowFocus는 유저가 다시 화면으로 돌아왔을 때 데이터를 새로고침할지 설정하는 것이고, false라서 창을 다시 열어도 새로고침되지 않는다. 예를 들어 게시판 메인 페이지에서 특정 글을 클릭해서 읽고 메인으로 다시 돌아와도 데이터는 새로고침되지 않는다. 필요한 경우(글 생성, 삭제)에만 데이터가 갱신되기 때문에 불필요한 서버 요청이 줄어서 훨씬 효율적으로 작동할 수 있다.
- 아래의 isLoading, error는 useQuery에서 제공하는 로딩/에러 처리 코드이다.
return (
<div className="b-wrap">
<div className="b-title">게시판</div>
<div className="addpost">
<button className="write" onClick={() => navigate('/newpost')}>
글쓰기
</button>
</div>
<div className="b-main">
<table>
<thead className="bt-head">
<tr className="bt-tr">
<th>이름</th>
<th>제목</th>
<th>작성일</th>
</tr>
</thead>
<tbody className="bt-body">
{posts.map((entry) => (
<tr key={entry.id} className="bt-tr checkpost">
<td>{entry.name}</td>
<td
className="bt-title"
onClick={() => navigate(`/board/${entry.id}`)}
>{entry.title}</td>
<td className="bt-date">{entry.date}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
- return문 내부의 코드는 너무 쉬워서 설명할게 많지 않지만..
가장 먼저 '글쓰기' 버튼 클릭시 '/newpost' 라는 엔드포인트로 이동하여 form 페이지를 볼 수 있도록 onClick으로 navigate()를 연결했다. - 실제 글 목록이 보여지는 부분은 posts.map() 메서드를 사용하여 게시글을 순차적으로 렌더링한다.
여기서 posts는 useQuery에서 가져온 데이터 변수이고, 각 게시글(entry)은 id를 키 값으로 활용해 고유하게 식별된다.
게시글의 제목을 클릭하면 해당 게시글의 상세 페이지로 이동하도록 navigate로 연결했다.
게시글 디테일 페이지
게시판 메인에서 특정 게시글의 제목을 클릭하면 보여지는 페이지.
여기서는 수정/삭제 버튼이 실제로 서버와 통신하며 작동하는 로직이 필요하다.
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import '../style/Board.scss';
const getPostDetail = async (id) => {
const postURL = `http://127.0.0.1:8001/board/${id}`;
const resp = await axios.get(postURL);
return resp.data;
};
- 마찬가지로 import문을 작성하면서 시작한다.
여기는 ID값으로 특정 게시글을 찾아서 보여줘야하는 페이지라서 useParams도 불러왔다. - 데이터를 불러오는 함수의 엔드포인트는 ID값으로 게시물을 추출해야 하니까 '템플릿 리터럴' 방식으로 가져왔다.
export default function Read() {
const { id } = useParams();
const navigate = useNavigate();
const {
data: post, error, isLoading,
} = useQuery({
queryKey: ['post', id],
queryFn: () => getPostDetail(id),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
// 이하 생략
}
- useParams는 URL에서 id를 추출하여 해당 게시글을 찾아 보여주기 위해 사용했고,
여기서도 역시 버튼 클릭시의 페이지 이동을 위해 navigate를 사용했다. - queryKey는 'post'라는 이름으로 데이터를 가져오고 id로 특정 게시글을 식별한다는 의미.
queryFn은 getPostDetail() 함수를 통해 id에 해당하는 게시글의 상세 데이터를 가져온다. - 게시판 메인 페이지에는 있는 enabled와 refetchOnWindowFocus가 여기에는 없는 이유는, 디테일 페이지에서는 이 부분들이 자동으로 실행되어야 하기 때문이다.
→ enabled는 컴포넌트가 렌더링될 때 바로 실행해야 디테일 페이지가 열리면서 자동으로 useQuery가 실행되어 게시글을 서버에서 가져와서 화면에 보여주니까 'true'여야 한다. 그리고 디테일 페이지는 글의 수정/삭제 로직이 있어서, 유저가 글을 수정하면 데이터가 새로고침되어서 수정된 글로 보여야하니까 refetchOnWindowFocus가 'true'여야 한다. enabled와 refetchOnWindowFocus는 작성하지 않으면 기본적으로 'true'로 설정되고, 마침 디테일 페이지에서는 둘 다 'true'여야하기 때문에 굳이 작성하지 않는다.
/* 게시글 수정으로 연결 */
const handleEdit = () => {
navigate(`/edit/${id}`);
};
/* 게시글 삭제로 연결 */
const handleDelete = async () => {
const confirmed = window.confirm('정말 삭제하시겠습니까?');
if (confirmed) {
try {
await axios.delete(`http://127.0.0.1:8001/board/${id}`);
alert('게시글 삭제 성공!🙌');
navigate('/board');
} catch (error) {
console.log('삭제 실패:', error);
alert('게시글 삭제 실패!');
}
}
};
- 다음으로는 버튼에 연결시킬 핸들러 함수 작성.
- 게시글을 수정하는 handleEdit 함수의 엔드포인트는 반드시 절대경로로 작성해야 한다.
"edit/${id}"라고 상대경로로 작성하면 현재 URL 뒤에 edit/${id}가 붙어서, 예를 들어 "board/3"에서 "board/3/edit/3"가 되어버린다. 반면에 "/edit/${id}"라고 절대경로로 작성하면 URL의 최상위 경로에서 시작하니까 기존 URL과 관계없이 "/edit/3"으로 바로 이동하게 된다. - 게시글을 삭제하는 handleDelete 함수에서 window.confirm()은 브라우저의 확인 대화상자를 띄워 사용자의 확인을 요청하는 메서드. 사용자가 확인을 누르면 true, 취소를 누르면 false를 반환한다.
if문을 통해 만약 사용자가 삭제를 '확인'한 경우(confirm가 true일 때), try문에 axios.delete()를 사용하여 특정 게시글을 삭제하는 요청을 보낸다. 삭제가 성공하면 사용자에게 alert로 알림을 보내고, 게시판 메인 페이지로 이동한다.
catch문을 통해 만약 삭제에 실패할 경우 콘솔에 에러 메시지를 띄우고, 화면에도 alert로 실패 메시지를 사용자에게 보여준다.
return (
<div className="r-wrap">
<div className="r-title">{post.title}</div>
<table className="r-table">
<tbody className="rt-body">
<tr>
<td className="r-key">이름</td>
<td className="r-value">{post.name}</td>
</tr>
<tr>
<td className="r-key">내용</td>
<td className="r-value">
{post.content.split('\n').map((line, index) => (
<span key={index}>
{line}
<br />
</span>
))}
</td>
</tr>
</tbody>
</table>
<div className="r-btns">
<button className="rb-edit" onClick={handleEdit}>
수정
</button>
<button className="rb-delete" onClick={handleDelete}>
삭제
</button>
</div>
</div>
);
- return문에는 앞서 useQuery에서 지정한 post 변수를 통해 게시글의 상세 내용을 보여준다.
- post.content.split().map()은 게시글의 내용을 spilt() 메서드를 통해 '\n'을 기준으로 잘라서, 각 줄을 map()으로 순차적으로 보여주는 것이다. 여기에서 index는 줄 번호, line은 실제 텍스트 내용이다.
게시글 등록/수정 페이지
마지막으로 게시글을 등록하거나 수정할 수 있는 form 페이지인데, 하나의 페이지에서 모두 다룰 수 있도록 만드는게 어려웠다. 새로운 글 생성은 아무 것도 없는 input을 보여주면 되지만, 기존의 글을 수정할 때는 서버에서 데이터를 가져와서 input에 보여줘야하기 때문이다.
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import '../style/Board.scss';
export default function Input() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { id } = useParams(); // 게시글 ID를 URL에서 가져옴
const isEdit = !!id; // ID가 있으면 수정 모드, 없으면 등록 모드
const [formData, setFormData] = useState({
name: '',
title: '',
content: '',
});
// 이하 생략
}
- 여기도 일단 import문부터 작성하면서 시작한다.
- useState: 게시글을 처음 생성할 때의 상태를 관리해서 사용자가 입력한 데이터를 저장하고, 상태가 변경될 때마다 컴포넌트를 리렌더링한다. 클라이언트에서 상태관리를 하다가, 유저가 나중에 '확인' 버튼을 누르면 데이터가 전부 서버로 보내지는 원리. 그래서 아래에 useState를 정의한 내용도 등록 모드일 경우에 입력창의 기본값을 빈 값으로 설정하겠다는 의미이다.
- useEffect: 유저가 글을 생성하는지 수정하는지 확인해서, 수정 모드일 경우 서버에서 가져온 데이터를 input창 안에 보여주고, 등록 모드면 비어있는 입력창을 보여준다.
- useMutation: 게시글을 등록/수정할 때, 서버에 요청을 보내고 결과를 처리하는 비동기 작업을 위해 사용된다. mutation 객체를 통해 요청의 성공 여부에 따라 후속 작업을 수행한다.
- useQueryClient: 리액트 쿼리에서 데이터 캐시를 관리하는 역할. 게시글을 등록/수정한 후, 캐시된 데이터를 무효화하여 최신 데이터를 다시 가져오도록 해줘서, 게시판 메인으로 돌아갔을 때 항상 최신 데이터가 보여지게 된다.
- const isEdit = !!id;
→ 내가 볼 때마다 헷갈리는 "2번 부정하는" 코드이다.😂
한번 부정했을 때에는 null, undefined, 0 등 id가 없을 경우에 true가 되고 있으면 false가 된다.
그런데 이걸 다시 한번 부정했으니, id가 있으면 true가 되어 수정 모드, 없으면 false가 되어 등록 모드이다.
솔직히 2번 부정할거면 그냥 연산자를 안 쓰는 것도 방법일거라고 생각했는데, 연산자를 사용하는 것은 코드의 의도를 더 명확하게 하고, isEdit 변수가 불리언 타입을 반환한다는 것을 보장해서 타입 에러를 줄이기 위함이라고 한다.
// 서버에서 기존 게시글 데이터 가져오기
const { data: postData, isLoading } = useQuery({
queryKey: ['post', id],
queryFn: async () => {
const postURL = `http://127.0.0.1:8001/board/${id}`;
const { data } = await axios.get(postURL);
return data;
},
enabled: isEdit, // 수정 모드일 때만 실행
});
- postData는 서버에서 가져온 기존 게시글 데이터를 저장하는 변수. 다른 페이지랑 이름이 같아서 변수명 변경됨.
- 'post'라는 이름의 queryKey로 데이터를 가져오고, id로 특정 게시글을 식별한다.
- queryFn은 특정 게시글을 가져오는 비동기 함수.
- "enabled: isEdit"는 수정 모드일 때만 useQuery를 실행하라는 설정.
// formData 초기화 (수정 모드일 때만 postData를 사용)
useEffect(() => {
if (isEdit && postData) {
setFormData({
name: postData.name,
title: postData.title,
content: postData.content,
});
}
}, [postData, isEdit]);
// 입력값 변경 핸들러
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
};
- useEffect는 수정 모드(isEdit)일 때, postData가 존재하면 setFormData를 사용해 서버에서 가져온 데이터를 입력창에 설정한다. postData가 변경될 때마다 이 콜백 함수가 실행되어, 새로운 데이터로 formData를 업데이트한다.
등록 모드일 경우에는 setFormData가 작동하지 않으므로 기본적으로 빈 값이 설정되어 입력창이 비어있게 된다. - 사용자가 입력창에서 값을 변경할 때마다 호출되는 핸들러 함수는 e.target으로 입력창에 들어오는 값을 받아서 name과 value에 넣는다. 여기서 name은 입력창의 이름(예: 'title', 'content' 등)이고, value는 입력된 실제 내용이다.
setFormData는 useState의 setter 함수로, 상태가 업데이트되면 변경된 값을 formData에 넣는다.
...formData는 스프레드 문법으로, formData 객체에 있는 모든 속성의 현재 상태를 새로운 객체로 복사한다. 그 후 [name]: value로 사용자가 변경한 값만 수정하여 최종적인 formData를 업데이트한다.
// 등록 및 수정 요청을 처리하는 mutation
const mutation = useMutation({
mutationFn: async () => {
if (isEdit) {
return await axios.put(`http://127.0.0.1:8001/board/${id}`, formData); // 수정 요청
} else {
return await axios.post('http://127.0.0.1:8001/board/', formData); // 등록 요청
}
},
onSuccess: () => {
queryClient.invalidateQueries('posts'); // 게시글 목록 데이터 갱신
navigate('/board'); // 게시판 메인으로 이동
},
});
// 등록 또는 수정 버튼 클릭 시 실행
const handleSubmit = () => {
mutation.mutate(); // 등록 또는 수정 mutation 실행
};
if (isLoading) return <div>Loading... ⏳</div>;
- 일단 useMutation도 React Query v5 버전부터는 {mutationFn: 함수} 처럼 객체 형식으로 작성해야 에러가 없다.
- mutationFn은 비동기적으로 작동하는 콜백함수이고, 여기서는 글의 수정/등록 요청을 처리하는 역할을 한다.
if문에서 수정(isEdit) 모드인 경우에는 axios.put() 요청을 통해 기존 게시글의 수정된 데이터를 formData 변수에 담아서 서버로 보내고, 등록 모드인 경우에는 axios.post()로 새로운 게시글을 formData 변수에 담아서 서버에 추가한다. - onSuccess는 mutationFn이 요청을 성공적으로 완료하면 실행되는 부분이다.
맨 처음에 import한 queryClient 객체를 통해 캐시된 데이터를 관리할 수 있고, invalidateQueries() 메서드는 특정 쿼리를 무효화시키고 최신 데이터를 다시 가져오게 한다. 그래서 이 부분을 해석하면, queryClient로 캐시된 'posts' 데이터를 무효화시키고 서버에서 최신 데이터(글 목록)를 다시 가져오도록 한다. - 그러므로 useMutation은 mutationFn에서 if문을 통해 수정/등록 요청에 대한 데이터를 formData에 담아서 서버로 보내고, mutationFn에서 수정/등록 요청이 성공하면 onSuccess가 동작하여, 캐시된 글 목록 데이터를 초기화시키고 최신 데이터로 다시 가져와서, 수정됐거나 등록된 데이터를 포함한 글 목록을 게시판 메인에 보여준다.
- 게시글 등록/수정 페이지의 '확인' 버튼에 연결되는 핸들러 함수에는, 실제로 서버에 등록/수정 요청을 보내는 로직이 담겨있다. 여기서의 'mutation'은 앞서 useMutation을 정의할 때 사용했던 변수이고, mutate() 메서드는 서버 요청을 실제로 트리거하는 역할을 한다. 그래서 mutate()를 호출하면 useMutation에 정의된 mutationFn이 실행되며 서버와 비동기 통신이 시작된다. 유저가 버튼을 클릭하면 실제로 작업이 이뤄지도록 하는 핵심 기능이다.
return (
<div className="i-wrap">
<div className="i-title">{isEdit ? '글 수정하기' : '글 작성하기'}</div>
<div className="i-area">
<table className="i-form">
<tbody>
<tr>
<th className="i-key">이름</th>
<td className="i-value">
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
</td>
</tr>
<tr>
<th className="i-key">제목</th>
<td className="i-value">
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
/>
</td>
</tr>
<tr>
<th className="i-key">내용</th>
<td className="i-value">
<textarea
name="content"
cols="30"
rows="10"
value={formData.content}
onChange={handleChange}
></textarea>
</td>
</tr>
</tbody>
</table>
<div className="submit">
<button className="i-btn" type="button" onClick={handleSubmit}>
{isEdit ? '수정' : '등록'}
</button>
</div>
</div>
</div>
);
- 그리고 마지막으로 return문 작성.
- 최상단의 페이지 제목과 최하단의 버튼은 모두 삼항연산자를 사용해서, 수정(isEdit) 모드일 경우에는 '수정'으로, 이외의 경우에는 '등록'으로 텍스트가 동적으로 설정된다.
- 각 입력 필드(input과 textarea)는 value 속성을 통해 formData에 연결되어 있고, onChange 핸들러를 통해 입력값을 실시간으로 업데이트한다. 이렇게 입력한 값들은 formData에 저장되고, 클라이언트는 현재 입력 상태를 계속 기억하다가 서버로 보낸다.
- 마지막으로 '등록/수정' 버튼을 클릭하면 handleSubmit 핸들러가 실행되어, 현재까지 입력된 데이터를 서버로 전송한다. 다시 말하면, 유저가 버튼을 클릭하는 순간, 작성한 정보가 서버에 최종 반영될 수 있도록 모든 코드 로직이 작동하게 되는 것이다.
여기까지가 이번 게시판 페이지 제작과 관련한 프론트엔드 코드 설명이었다.
요즘 React Query를 계속 연습하고 있어서, useQuery는 어렵지 않았는데 useMutation은 이번이 처음 제대로 사용해본거라 어려웠다. 얼른 익숙해질 수 있게 노력해야지.🌱
