6. 방명록 프론트엔드 구현 [MyHomepage: 개인 홈페이지 만들기]
안녕하세요! 개발자 최영철입니다.
이번 포스트에서는 방명록의 프론트엔드를 전부 구현해 결과적으로 아래의 사진과 같이 만들어보겠습니다!

목차
1. guest.js
방명록과 관련된 기능들을 여러 컴포넌트로 쪼개서 붙여넣기 위해 모든 컴포넌트를 관리할 방명록 최상위 컴포넌트가 필요합니다. 저는 이 컴포넌트를 GuestBook 컴포넌트라고 선언하고 이를 guest.js로 저장했습니다. 아래 그림은 방명록 컴포넌트를 구현할 때 어떤식으로 구현할지 대략적으로 나타낸 그림입니다.

사람마다 원하는 배치가 다르고 디자인이 다르기 때문에 CSS에 대한 설명은 패스하도록 하겠습니다. 혹시 해당 CSS가 궁금하시다면 제 GitHub의 Public Repository인 MyHomepage Frontend에서 코드를 참고해주세요.
function GuestBook() {
//...
const [value, fetchData] = useFetch([], [], "guestbook/");
//...
return (
<GuestGridContainer>
{value.map((data, idx) => {
return (
<GuestItem key={idx} { ... data }/>
)
})}
</GuestGridContainer>
)
}
위의 코드는 Fetch한 데이터를 화면에 표시하는 방법을 나타내는 코드입니다. 저번 포스트에서 데이터를 fetch하기 위해 useFetch 훅을 만들었습니다. 이를 이용하면 value 값으로 API한테서 받은 데이터를 사용할 수 있습니다. 배열로 받은 데이터는 {value.map} 같이 iteration 함수를 이용하면 각각의 정보를 갖고 있는 별개의 GuestItem 컴포넌트를 생성하고 화면에 표시할 수 있습니다. 예를 들어 다음과 같은 데이터를 value로 저장했다고 합시다.
[
{
gid: 1,
type: 0,
content: "테스트입니다.",
created_at: "2022-11-16T15:15:15.532805Z"
},
{
gid: 2,
type: 1,
nickname: "홍길동",
content: "테스트입니다.",
created_at: "2022-11-16T15:15:15.532805Z"
},
...
]
그러면 value.map을 통해 gid가 1인 데이터를 가지고 있는 GuestItem 컴포넌트와 gid가 2인 데이터를 가지고 있는 GuestItem이 생성되고 두 개의 GuestItem 모두 GuestGridContainer 컴포넌트에서 화면에 표시되게 됩니다. 그렇다면 해당 방명록의 gid를 알고 있는 GuestItem 컴포넌트에서 방명록 삭제 함수를 gid와 함께 호출한다면 원하는 방명록을 지울 수 있게 됩니다. 또한, 데이터를 지우고 바로 갱신하고싶으면 useFetch의 fetchData를 각 GuestItem에게 보내거나 GuestBook 컴포넌트에서 state를 이용해 state의 값이 바뀌면 useEffect로 다시 갱신하는 방법이 있을 수 있습니다. 이러한 사실을 응용하여 다음과 같은 코드를 구현했습니다.
function GuestBook() {
...
const [value, fetchData] = useFetch([], [], "guestbook/");
...
function deleteData(props) {
const { gid, type } = props;
const deleteGuest = async () => {
const token = cookies.userInfo ? cookies.userInfo.token : null;
axios
.delete("http://localhost:8000/guestbook/", {
...,
data: {
gid
},
})
.then(() => {
fetchData();
})
.catch((e) => {
fetchData();
});
};
deleteGuest();
}
...
return (
<GuestGridContainer>
{value.map((data, idx) => {
return (
<GuestItem key={idx} deleteData={deleteData} { ... data }/>
)
})}
</GuestGridContainer>
);
}
const GuestItem = (props) => {
const {
deleteData,
gid,
nickname,
type,
content,
created_at,
} = props;
const date = new Date(created_at);
return (
<GuestGrid>
<div className="title">{date.toLocaleDateString("ko-KR")}</div>
<div className="delete" onClick={() => deleteData(gid, type)}>
삭제
</div>
<div className="author">{nickname ? nickname : "익명"}</div>
<div className="content">
<GuestContent content={content} />
</div>
</GuestGrid>
);
};
최상위 방명록 컴포넌트인 GuestBook에서 정의된 deleteData를 각각의 GuestItem 컴포넌트에 보내서 삭제 버튼을 누르면 deleteData 함수를 호출할 수 있도록 했습니다. 이런 식으로 하나의 방명록을 하나의 GuestItem으로 GuestBook이 제어할 수 있게 됩니다. deleteGuest 함수에서 async를 사용한 이유는 데이터가 fetch 될 때까지 기다리지 않도록 하기 위함입니다.
2. guest_sender.js
내용 입력창을 GuestSender 컴포넌트로 다른 파일로 저장한 것은 메인 컴포넌트인 방명록 컴포넌트의 코드의 길이를 줄일 목적과 함께 전송 상태를 확인시켜주는 알림 모달창을 이에 관련된 GuestSender에서 출력함에 따라 관리의 수월성을 높이기 위해서 입니다.

다음과 같이 하위 컴포넌트인 GuestSender에서 모달창을 발생시켜도 z-index에 따라 모달창의 백그라운드와 상호작용 할 수 없으므로 알림창에 역할을 수행할 수 있습니다. 따라서 전송 여부의 따라 알림창을 띄우는 기능은 다음과 같은 코드로 구현했습니다.
function GuestSender(props) {
...
const [fail, setFail] = useState(false);
const [success, setSuccess] = useState(false);
...
const failBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setFail(false),
};
const successBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setSuccess(false),
};
const postMsg = () => {
...
axios.post(
...
)
.then(() => {
...
setSuccess(true)
})
.catch(() => {
...
setFail(true)
})
...
};
...
return (
...
{success && (
<Modal
title="알림"
firstBtn={successBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setSuccess(false)}
>
<div>성공적으로 전송되었습니다.</div>
</Modal>
)}
{fail && (
<Modal
title="알림"
firstBtn={failBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setFail(false)}
>
<div>전송에 실패했습니다.</div>
</Modal>
)}
...
);
}
useState를 이용해 성공할 때와 실패할 때 사용하는 state를 구분합니다. 방명록을 전송하는 postMsg 함수에서 만약 전송에 성공한다면 setSuccess를 통해 값을 바꿔주면 {success && } 부분의 모달창을 표시할 수 있습니다. 이와 반대로 실패해서 fail의 값이 true가 된다면 {fail && } 부분의 모달창을 표시할 수 있습니다. 이러한 알림 모달창 용도 말고도 event 부분에 원하는 함수를 넣는다면 다양한 역할을 하는 모달창을 만들 수 있습니다. 이는 이후 설명할 GuestAnon 컴포넌트에서 적용됩니다.



다음으로 이 컴포넌트에서 익명과 로그인한 유저를 구분해 익명일 경우 반드시 암호를 입력하는 Input을 표시하고, 유저의 경우 익명으로 보낼 것인지 유저 이름으로 보낼 것인지 선택하는 checkBox Input과 익명 체크를 한다면 암호를 입력하는 Input이 나타나도록 코드를 작성했습니다.
먼저 익명인지 로그인한 유저인지 구별하는데 사용한 방법은 쿠키를 이용했습니다. 로그인할 때 쿠키로 토큰을 저장하도록 했기때문에 로그인한 유저만 쿠키를 가지고 있으므로 쿠키의 존재 여부로 판별했습니다. 쿠키의 저장과 활용은 react-cookie 라이브러리를 사용했습니다.
const [cookies, setCookie] = useCookies(["userInfo"]);
const [isAuth, setAuth] = useState(cookies.userInfo ? true : false);
위의 코드를 통해 유저가 로그인 되어있는지 (isAuth)를 알 수 있습니다. 이렇게 isAuth 값을 얻었으므로 { isAuth && (컴포넌트) }를 통해 로그인한 유저가 볼 컴포넌트를 정할 수 있고, { !isAuth && (컴포넌트) }로 로그인하지 않은 유저가 볼 컴포넌트를 정할 수 있습니다. 그런데 익명의 유저인 경우 isAuth를 통해 바로 암호입력창을 나타내면 되지만 로그인한 유저의 경우 isAuth만으로는 이를 나눠서 나타낼 수 없습니다. 이것을 해결하기 위해 isAnon이라는 state를 추가하고 기본적으로 isAuth가 true면 isAnon이 false, isAuth가 false면 isAnon이 true가 되도록해 로그인한 유저도 익명으로 방명록을 보낼 수 있도록 했습니다. 이렇게 설정한 isAnon은 로그인한 유저가 익명으로 보낼 것인지 선택하는 checkbox input의 value로 사용해 isAnon에 따라 자동으로 값이 변하고 onChange 이벤트는 해당 isAnon을 토글하는 함수로 구현했습니다. 이렇게 isAnon을 checkbox의 체크 여부에 따라 설정할 수 있었습니다. 다음으로 암호 입력을 담당하는 input 컴포넌트는 pw로 암호를 저장하는 state를 새로 만들어 pw를 value로 사용하고 onClick 이벤트에서 보내주는 e 파라미터로 value를 참조해 유저가 적은 암호를 pw 값에 할당할 수 있도록 했습니다. 다음은 그 코드의 일부입니다.
const [isAnon, setAnon] = useState(isAuth ? false : true);
const [pw, setPw] = useState(null);
const handlePw = (e) => setPw(e.target.value);
<div className="anonCheck">
{isAuth && (
<input
type="checkbox"
id="anon"
value={isAnon}
onChange={() => setAnon(!isAnon)}
/>
)}
{isAuth && <label for="anon">익명으로 작성하기</label>}
{!isAuth && <div>익명</div>}
</div>
<div className="pwInputs">
{(!isAuth || isAnon) && (
<Input
type="password"
onChange={handlePw}
value={pw}
placeholder="암호 입력"
></Input>
)}
</div>
위의 구현이 끝난 후, 추가적으로 유저가 작성한 방명록을 API에게 POST 하는 함수, 암호 입력창처럼 내용 입력창을 처리하는 state와 함수를 구현해 최종적으로 GuestSender를 구현할 수 있었습니다.
다음 코드가 guest_sender.js에 사용된 최종 코드입니다.
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import AuthVerify from "./auth";
import { useCookies } from "react-cookie";
import Modal from "./modal";
import { axiosAPI } from "./api_util";
function GuestSender(props) {
const [msg, setMsg] = useState("");
const [pw, setPw] = useState("");
const [isSubmit, setSubmit] = useState(false);
const [cookies, setCookie] = useCookies(["userInfo"]);
const [isAuth, setAuth] = useState(cookies.userInfo ? true : false);
const [isAnon, setAnon] = useState(isAuth ? false : true);
const [fail, setFail] = useState(false);
const [success, setSuccess] = useState(false);
const { fetchGuests } = props;
const failBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setFail(false),
};
const successBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setSuccess(false),
};
useEffect(() => {
setAuth(cookies.userInfo ? true : false);
}, [cookies]);
const handleMsg = (e) => {
setMsg(e.target.value);
};
const handlePw = (e) => {
setPw(e.target.value);
};
const postMsg = () => {
const type = !isAuth || isAnon ? 0 : 1;
if (type == 1) {
if (!AuthVerify({ isReload: true })) {
return false;
}
}
const doSuccess = () => {
setSubmit(true);
fetchGuests();
setSuccess(true);
};
const doFail = () => {
fetchGuests();
setFail(true);
};
switch (type) {
case 0:
if (pw.length < 8) {
alert("비밀번호를 8글자 이상으로 해주세요.");
} else {
axiosAPI(
"guestbook/",
{
type: type,
password: pw,
title: "방명록",
content: msg,
},
"post",
null,
() => {
setPw("");
doSuccess();
},
doFail
);
}
break;
case 1:
axiosAPI(
"guestbook/",
{
type: type,
title: "방명록",
content: msg,
},
"post",
{
headers: {
Authorization: `Bearer ${cookies.userInfo.token}`,
},
},
doSuccess,
doFail
);
break;
default:
setSubmit(true);
break;
}
};
useEffect(() => {
if (isSubmit) {
setMsg("");
setSubmit(false);
}
}, [isSubmit]);
return (
<Sender>
{success && (
<Modal
title="알림"
firstBtn={successBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setSuccess(false)}
>
<div>성공적으로 전송되었습니다.</div>
</Modal>
)}
{fail && (
<Modal
title="알림"
firstBtn={failBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setFail(false)}
>
<div>전송에 실패했습니다.</div>
</Modal>
)}
<div className="anonCheck">
{isAuth && (
<input
type="checkbox"
id="anon"
value={isAnon}
onChange={() => setAnon(!isAnon)}
/>
)}
{isAuth && <label for="anon">익명으로 작성하기</label>}
{!isAuth && <div>익명</div>}
</div>
<div className="pwInputs">
{(!isAuth || isAnon) && (
<Input
type="password"
onChange={handlePw}
value={pw}
placeholder="암호 입력"
></Input>
)}
</div>
<div className="inputs">
<NewText rows="5" onChange={handleMsg} value={msg}></NewText>
</div>
{(msg.length != 0 || (isAnon && pw.length >= 8)) && (
<div className="submitBtn" onClick={() => postMsg()}>
전송
</div>
)}
{(msg.length == 0 || (isAnon && pw.length < 8)) && (
<div className="submitBtnDisabled">전송</div>
)}
</Sender>
);
}
//나머지는 CSS 부분
export default GuestSender;
3. guest_anon_pw.js

방명록을 삭제할 때 익명 타입이면 삭제 암호를 입력하는 컴포넌트입니다. 기본적으로 위의 GuestSender에서 암호 입력창을 구현하는 내용과 비슷하지만 이 경우 암호 입력 모달창을 띄우고 전송을 누르면 다시 한번 확인하는 모달창을 띄웁니다. 이때 다시 한번 삭제 버튼을 누르면 암호 입력 모달창과 확인 모달창을 동시에 닫아야하므로 부모 컴포넌트에서 암호 입력 모달 컴포넌트에게 해당 컴포넌트를 표시하는데 사용되는 state를 바꿀 수 있는 함수를 제공하고 이를 다시 확인 모달창에 제공해 모달창을 동시에 끌 수 있도록 구현했다는 차이점이 있습니다. 이 컴포넌트는 다음과 같은 코드로 구현됐습니다.
import React, { useState } from "react";
import styled from "styled-components";
import Modal from "./modal";
function GuestAnonDelete(props) {
const { closeModal, deleteData, gid } = props;
const [isModal, setModal] = useState(false);
const [pw, setPw] = useState("");
const firstBtn = {
event: () => {
setModal(false);
doDelete();
},
name: "삭제",
style: {
backgroundColor: "red",
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
};
const secondBtn = {
event: () => {
setModal(false);
},
name: "취소",
style: {
backgroundColor: "grey",
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
};
const modalStyle = {
height: "130px",
};
const handlePw = (e) => setPw(e.target.value);
const doDelete = () => {
closeModal();
deleteData({ gid, password: pw });
};
return (
<GuestAnon>
{isModal && (
<Modal
title={"삭제"}
firstBtn={firstBtn}
secondBtn={secondBtn}
style={modalStyle}
zIndex={150}
closeModal={() => setModal(false)}
>
<div style={{ fontSize: "15px" }}>정말로 삭제하시겠습니까?</div>
</Modal>
)}
<div className="pwInputs">
<Input
type="password"
onChange={handlePw}
value={pw}
placeholder="암호 입력"
></Input>
</div>
{pw.length >= 8 && (
<div className="submitBtn" onClick={() => setModal(true)}>
전송
</div>
)}
{pw.length < 8 && <div className="submitBtnDisabled">전송</div>}
</GuestAnon>
);
}
//나머지는 CSS
export default GuestAnonDelete;
4. 최종 guest.js
이렇게 만든 컴포넌트들을 GuestSender 컴포넌트에 조립해서 다음과 같은 guest.js를 구현했습니다.
import React, { useEffect, useState } from "react";
import "./guest.css";
import styled from "styled-components";
import GuestSender from "./guest_sender";
import Modal from "./modal";
import GuestAnonDelete from "./guest_anon_pw";
import AuthVerify, { AuthContext } from "./auth";
import { useNavigate } from "react-router-dom";
import { useCookies } from "react-cookie";
import { axiosAPI, useFetch } from "./api_util";
function GuestBook() {
const navigate = useNavigate();
const [cookies] = useCookies(["userInfo"]);
const [isModal, setModal] = useState(false);
const [isConfirm, setConfirm] = useState(false);
const [fail, setFail] = useState(false);
const [success, setSuccess] = useState(false);
const [value, fetchData] = useFetch([], [], "guestbook/");
const [deleteGid, setDeleteGid] = useState(null);
const modalStyle = {
height: "130px",
textShadow: "none",
};
const firstBtn = {
event: () => {
setConfirm(false);
deleteData({
gid: deleteGid,
type: 1,
});
},
name: "삭제",
style: {
backgroundColor: "red",
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
};
const secondBtn = {
event: () => {
setConfirm(false);
},
name: "취소",
style: {
backgroundColor: "grey",
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
};
const failBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setFail(false),
};
const successBtn = {
name: "확인",
style: {
borderRadius: "5px",
border: "1px solid rgba(50, 50, 50, 0.5)",
},
event: () => setSuccess(false),
};
const preDelete = (gid) => {
setDeleteGid(gid);
setConfirm(true);
};
function deleteData(props) {
const { gid, type, password } = props;
if (type) {
if (!AuthVerify({ isReload: true })) {
return false;
}
}
const deleteGuest = async () => {
const token = cookies.userInfo ? cookies.userInfo.token : null;
axiosAPI(
"guestbook/",
{ gid, password },
"delete",
{ Authorization: `Bearer ${token}` },
() => {
fetchData();
setSuccess(true);
},
() => {
fetchData();
setFail(true);
}
);
};
deleteGuest();
};
return (
<GuestContainer>
{isModal && (
<Modal
title="방명록 삭제"
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setModal(false)}
>
<GuestAnonDelete
deleteData={deleteData}
closeModal={() => setModal(false)}
gid={deleteGid}
/>
</Modal>
)}
{isConfirm && (
<Modal
title={"삭제"}
firstBtn={firstBtn}
secondBtn={secondBtn}
style={modalStyle}
zIndex={150}
closeModal={() => setConfirm(false)}
>
<div style={{ fontSize: "15px" }}>정말로 삭제하시겠습니까?</div>
</Modal>
)}
{success && (
<Modal
title="알림"
firstBtn={successBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setSuccess(false)}
>
<div>성공적으로 삭제되었습니다.</div>
</Modal>
)}
{fail && (
<Modal
title="알림"
firstBtn={failBtn}
zIndex={150}
style={{ height: "130px" }}
closeModal={() => setFail(false)}
>
<div>삭제에 실패했습니다.</div>
</Modal>
)}
<GuestGridContainer>
{value.map((guest, idx) => {
return (
<GuestItem
key={idx}
preDelete={preDelete}
setDeleteGid={setDeleteGid}
setModal={setModal}
{...guest}
/>
);
})}
</GuestGridContainer>
<GuestSender fetchGuests={fetchData} navigate={navigate} />
</GuestContainer>
);
}
const GuestItem = (props) => {
const {
preDelete,
setDeleteGid,
setModal,
gid,
nickname,
type,
content,
created_at,
} = props;
const date = new Date(created_at);
const doDelete = () => {
if (type === 0) {
setDeleteGid(gid);
setModal(true);
} else {
preDelete(gid);
}
};
return (
<GuestGrid>
<div className="title">{date.toLocaleDateString("ko-KR")}</div>
<div className="delete" onClick={() => doDelete()}>
삭제
</div>
<div className="author">{nickname ? nickname : "익명"}</div>
<div className="content">
<GuestContent content={content} />
</div>
</GuestGrid>
);
};
const GuestContent = (props) => {
const { content } = props;
if (content === "" || !content) {
return "내용이 없습니다.";
} else {
return (
<div>
{content.split("\\n").map((line, idx) => {
return (
<div>
{line}
<br />
</div>
);
})}
</div>
);
}
};
//나머지는 CSS
export default GuestBook;
위에서 설명하지 않은 state인 deleteGid와 preDelete에 대해 설명 드리자면 익명 타입의 게시글을 삭제하기 위해 비밀번호 입력 컴포넌트는 GuestAnonDelete 컴포넌트를 Guest 컴포넌트에서 호출합니다. GuestAnonDelete에서 비밀번호를 입력하면 삭제를 진행해야하는데 Guest 컴포넌트는 gid 정보를 가지고 있지 않습니다. gid 정보를 가지고 있는 컴포넌트는 GuestItem 컴포넌트이므로 부모 컴포넌트에게 삭제할 gid를 보내주기 위해 deleteGid를 추가했습니다. 또한 GuestItem에서 삭제 버튼을 누르면 Guest에서 삭제 확인 모달창이나 GuestAnonDelete 모달창을 열기 위해 preDelete 함수를 추가했습니다. 그래서 preDelete는 gid를 인수로 받고 deleteGid를 설정하는 함수와 삭제 확인창을 열기 위해 isConfirm을 설정하는 함수를 호출합니다.
5. 마무리
아무래도 이번 포스트에서는 방명록 기능을 어떻게 구현할 것인가 접근했던 방법을 설명하고 그에 대한 코드를 소개했기 때문에 다소 코드가 많고 긴 포스트가 되었던 것 같습니다.
다음 포스트에서는 Project 백엔드 구현에 대한 이야기를 하겠습니다.
감사합니다!!