독서 감상 공유 플랫폼(이하 bovo 프로젝트)에서 서비스되고 있는 것들 중 독서 토론방의 독서 기록 공유 버튼을 클릭할때마다 화면에 아무것도 랜더링되지 않으면서 아래와 같은 에러가 발생하였다.

고질적으로 발생되는 독서 토론방(채팅방) 서비스 문제를 이 기회에 완전히 해결하고자 한다.
우선 문제 원인을 파악하기 위해서는 해당 프로젝트의 동작원리에 대해서 이해할 필요가 있다고 느꼈다. 독서기록 공유 버튼이 존재하는 부모 컴포넌트는 ChatLayout > ChatInputContainer 이다.
const ChatLayout = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { roomId } = useParams();
const location = useLocation();
const roomName = location.state?.roomName; // state로부터 roomName을 추출
// ✅ Redux에서 채팅 메시지 가져오기
const chatMessages = useSelector((state) => state.chat.chatMessages);
const [modalOpen, setModalOpen] = useState(false); //독서 기록 공유 모달 상태 관리
const [selectedMemos, setSelectedMemos] = useState([]); // 메모 보내기
const { data: memos, refetch: refetchMemos } = useQuery({
queryKey : ['memos', roomId],
queryFn : () => getMemos(roomId),
enabled: false // 초기에는 실행하지 않음
});
// 기록 공유 모달 오픈 관련 함수
const handleOpenModal = useCallback(async () => {
try {
refetchMemos(); // 메모 목록 refetch
setSelectedMemos([]); // 모달 열 때 선택된 메모 초기화
setModalOpen(true); // 모달 열기
} catch (error) {
console.error("메모 데이터를 가져오는 데 실패했습니다:", error);
}
}, [refetchMemos]); // refetchMemos만 의존성에 추가
const handleCloseModal = useCallback(() => {
setModalOpen(false);
setSelectedMemos([]); // 모달 닫을 때 선택된 메모 초기화
}, []); // 빈 의존성 배열로 한 번만 생성되도록 함
// ✅ WebSocket 연결 및 메시지 관리
useEffect(() => {
const onMessageReceived = (message) => {
dispatch(addMessage(message)); // Redux에 메시지 저장
};
connectChat(roomId, onMessageReceived);
return () => {
disconnectChat();
dispatch(clearChat()); // ✅ Redux 상태 초기화
};
}, [roomId, dispatch]);
// ✅ 새로고침 후 로컬 스토리지에서 메시지 복원
useEffect(() => {
const storedMessages = JSON.parse(sessionStorage.getItem('chatMessages')) || [];
if (chatMessages.length === 0) {
storedMessages.forEach((message) => {
dispatch(addMessage(message)); // Redux에 메시지 추가
});
}
}, [dispatch]);
const handleSelectMemo = useCallback((memo, checked) => {
// 선택된 메모 업데이트
if (checked) {
setSelectedMemos((prevSelected) => [...prevSelected, memo]);
} else {
setSelectedMemos((prevSelected) => prevSelected.filter(item => item !== memo));
}
}, []); // 빈 의존성 배열
const handleShareMemo = useCallback(() => {
if (selectedMemos.length > 0) {
selectedMemos.forEach(memo => {
const message = `${memo.memo_Q} : ${memo.memo_A}`; // 책 제목과 메모 질문을 결합
sendChatMessage(roomId, message); // 메모 질문을 메시지로 보내기
});
setSelectedMemos([]); // 전송 후 선택된 메모 초기화
handleCloseModal(); // 모달 닫기
}
}, [selectedMemos, roomId, handleCloseModal]); // selectedMemos, roomId, handleCloseModal은 의존성에 포함
return (
<Container className={styles.layout}>
<Header toggleSidebar={toggleSidebar} roomName={roomName} />
<Sidebar
open={sidebarOpen}
toggleSidebar={toggleSidebar}
roomName={roomName}
handleExitClick={handleExitClick}
userList={userList} // 사용자 목록 전달
/>
<ForumChat chatMessages={chatMessages} />
<ChatInputContainer
roomId={roomId}
sendChatMessage={sendChatMessage} // api 함수를 직접 전달
onOpenModal={handleOpenModal} // 모달 열기 함수 전달
/>
<ReadingShareModal
open={modalOpen}
onClose={handleCloseModal}
handleSelectMemo={handleSelectMemo} // memo 선택 함수 추가
handleShareMemo={handleShareMemo} // 확인 버튼 클릭 시 호출되는 함수
memos={memos} // 메모 데이터를 전달
/>
</Container>
);
};
export default ChatLayout;
컴포넌트 코드가 길어서 필요한 부분만 발췌했으며, 독서기록 공유 버튼은 ChatInputContainer 컴포넌트 속에 존재하며, 이 버튼을 클릭시 나타나는 독서기록공유 모달은 ReadingShareModal이다. 독서 기록 공유 버튼을 클릭하게 되면 handleOpenModal 함수가 작동하게 되고, 작동에 의해 얻게 된 memos(독서 기록 data)는 그대로 ReadingShareModal에게 props로 전달되게 된다.
const ReadingShareModal = ({ open, onClose, memos, handleSelectMemo, handleShareMemo }) => {
const [checkedItems, setCheckedItems] = useState([]);
// 모달이 열릴 때마다 checkedItems 초기화
useEffect(() => {
if (open) {
setCheckedItems(new Array(memos.length).fill(false)); // memos 길이에 맞게 초기화
}
}, [open, memos]);
// 선택된 메모를 부모 컴포넌트로 전달하는 함수
const handleCheckboxChange = (index, checked) => {
const updatedCheckedItems = [...checkedItems];
updatedCheckedItems[index] = checked;
setCheckedItems(updatedCheckedItems);
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
sx={{
"& .MuiDialog-paper": {
width: "36.5625rem",
height: "59.75rem",
padding: "3.5rem 2.75rem 2.5rem 2.6875rem",
display: "flex",
alignItems: "center",
backgroundColor: "#E8F1F6",
borderRadius: "1.5625rem",
},
}}
>
<Box className={styles.modalHeader}>
<DialogTitle
sx={{fontSize: "2rem", fontWeight: "bold"}}
>
내 템플릿 공유하기
</DialogTitle>
<IconButton onClick={onClose}>
<CloseIcon sx={{fontSize: "2rem"}} />
</IconButton>
</Box>
<DialogContent>
{memos && memos.length > 0 ? (
memos.map((memo, index) => (
<List key={index} className={styles.templateList}>
<TemplateListItem
memo={memo}
checked={checkedItems[index] || false}
handleCheckboxChange={(checked) => {
handleCheckboxChange(index, checked); // 체크박스 상태 변경
handleSelectMemo(memo, checked); // 부모 컴포넌트에서 선택된 메모 처리 함수 호출
}}
/>
</List>
))
) : (
<Box>데이터가 없습니다.</Box>
)}
</DialogContent>
<DialogActions>
<Button
className={styles.addBtn}
sx={{
backgroundColor: "#FFFFFF",
borderRadius: "1.5625rem",
fontSize: "2rem",
letterSpacing: "0.02rem",
color: "#739CD4"
}}
onClick={handleShareMemo}
>
추가하기
</Button>
</DialogActions>
</Dialog>
);
};
export default ReadingShareModal;
이렇게 전달된 memos는 List 형태로 모달에 뿌려지게 된다.
문제의 원인이 되는 것은 바로 독서기록공유 모달을 여는 handleOpenModal 함수에서 기인한다. handleOpenModal함수를 다시 살펴보면
// 기록 공유 모달 오픈 관련 함수
const handleOpenModal = useCallback(async () => {
try {
refetchMemos(); // 메모 목록 refetch
setSelectedMemos([]); // 모달 열 때 선택된 메모 초기화
setModalOpen(true); // 모달 열기
} catch (error) {
console.error("메모 데이터를 가져오는 데 실패했습니다:", error);
}
}, [refetchMemos]); // refetchMemos만 의존성에 추가
위와 같은데 여기서 async를 사용하여 비동기 함수와 동기함수의 실행 순서(위→아래)는 보장해주었지만 refetchMemos(), 즉 버튼을 클릭했을 때 데이터를 가져오는 함수에 await를 붙여주지 않아, 코드의 순서대로 실행완료 순서를 보장해주지는 못했다.
이에 따라 비동기 함수가 독서 기록 data를 가져오기 전에 setModalOpen에 의해 모달이 열리고 memos props를 ReadingShareModal에 전달되면서 data를 아직 다 가져오지 못했기에 undefined가 된 memos에 의해 TypeError가 발생된 것이다. 실행 순서만 신경쓰고 실행 완료시점은 간과한 것이 에러로 나타나게 되었다.
문제의 해결 방법은 크게 2가지이다.
실행 완료 시점을 보장하지 못했으므로 refetchMemos() 앞에 await를 붙여 실행완료 순서까지 보장하는 것까지는 2가지 방법 모두 동일하나 memos props를 전달하는 방식에서 다음과 같이 나뉘어진다.
혹시나 독서기록을 작성하지 않은 유저가 있을 수 있기 때문에 memos props를 전달할때 memos={memos || []} 로 memos 데이터가 없다면 빈 배열을 전달하여 에러를 해결한다.
useQuery를 최대한 활용하여 isLoading, isError, data 상태별로 분류하여 memos(독서기록)을 조건부로 rendering한다.
2가지 방법 모두 장/단점이 있으나, 유저의 서비스 경험 면에서 2번째 방법이 사이트와 상호작용한다는 느낌을 준다고 생각하여 채택했다.
const ChatLayout = () => {
const {
data: memos,
refetch: refetchMemos,
isPending: memosLoading, // 로딩 중 상태
isError: memosError // 에러 발생 상태
} = useQuery({
queryKey : ['memos', roomId],
queryFn : () => getMemos(roomId),
enabled: false // 초기에는 실행하지 않음
});
// 기록 공유 모달 오픈 관련 함수
const handleOpenModal = useCallback(async () => {
try {
await refetchMemos(); // 메모 목록 refetch
setSelectedMemos([]); // 모달 열 때 선택된 메모 초기화
setModalOpen(true); // 모달 열기
} catch (error) {
console.error("메모 데이터를 가져오는 데 실패했습니다:", error);
toast.error("메모 데이터를 가져오는 데 실패했습니다");
}
}, [refetchMemos]); // refetchMemos만 의존성에 추가
return (
<Container className={styles.layout}>
<Header toggleSidebar={toggleSidebar} roomName={roomName} />
<Sidebar
open={sidebarOpen}
toggleSidebar={toggleSidebar}
roomName={roomName}
handleExitClick={handleExitClick}
userList={userList} // 사용자 목록 전달
/>
<ForumChat chatMessages={chatMessages} />
<ChatInputContainer
roomId={roomId}
sendChatMessage={sendChatMessage} // api 함수를 직접 전달
onOpenModal={handleOpenModal} // 모달 열기 함수 전달
/>
<ReadingShareModal
open={modalOpen}
onClose={handleCloseModal}
handleSelectMemo={handleSelectMemo} // memo 선택 함수 추가
handleShareMemo={handleShareMemo} // 확인 버튼 클릭 시 호출되는 함수
memos={memos} // 메모 데이터를 전달
isLoading={memosLoading} // ⭐ 로딩 상태 전달
isError={memosError} // ⭐ 에러 상태 전달
/>
{/* 채팅방 나가기 모달 */}
<DeleteChatRoomModal
open={exitModalOpen}
onClose={handleCancelExit}
onConfirm={handleConfirmExit}
/>
</Container>
);
};
export default ChatLayout;
handleOpenModal의 memo 데이터를 불러오는 함수인 refetchMemos() 앞에 await를 붙임으로써 독서 기록 데이터를 불러오기 전에 modal을 열 수 없도록 한다.
독서 기록을 불러오는 useQuery 함수로부터 isPending, isError, data로 분해하여 ReadingShareModal의 props로 전달한다.
import PropTypes from 'prop-types'; // PropTypes 임포트
import Box from '@mui/material/Box';
import Typography from "@mui/material/Typography";
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import CloseIcon from '@mui/icons-material/Close';
import styles from "./ReadingShareModal.module.css";
import { useEffect, useState } from "react";
import TemplateListItem from '../templateListItem/TemplateListItem';
import LoadingSpinner from '../../loadingSpinner/LoadingSpinner';
const ReadingShareModal = ({
open,
onClose,
memos,
handleSelectMemo,
handleShareMemo,
isLoading,
isError
}) => {
const [checkedItems, setCheckedItems] = useState([]);
// 모달이 열릴 때마다 checkedItems 초기화
useEffect(() => {
if (open) {
setCheckedItems(new Array(memos.length).fill(false)); // memos 길이에 맞게 초기화
}
}, [open, memos]);
// 선택된 메모를 부모 컴포넌트로 전달하는 함수
const handleCheckboxChange = (index, checked) => {
const updatedCheckedItems = [...checkedItems];
updatedCheckedItems[index] = checked;
setCheckedItems(updatedCheckedItems);
};
// ⭐ 로딩 및 에러 상태에 따른 조건부 렌더링 추가
let content;
if (isLoading) {
content = (
<LoadingSpinner />
);
} else if (isError) {
content = (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', flexDirection: 'column' }}>
<Typography color="error" sx={{ textAlign: 'center' }}>메모를 불러오는 데 실패했습니다.<br/>잠시 후 다시 시도해주세요.</Typography>
</Box>
);
} else if (memos && memos.length > 0) { // memos가 존재하고 내용이 있을 때
content = memos.map((memo, index) => (
<List key={index} className={styles.templateList}>
<TemplateListItem
memo={memo}
checked={checkedItems[index] || false} // checkedItems[index]가 undefined일 경우를 대비하여 || false 추가
handleCheckboxChange={(checked) => {
handleCheckboxChange(index, checked);
handleSelectMemo(memo, checked);
}}
/>
</List>
));
} else { // memos가 없거나 빈 배열일 때
content = <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>데이터가 없습니다.</Box>;
}
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
sx={{
"& .MuiDialog-paper": {
width: "36.5625rem",
height: "59.75rem",
padding: "3.5rem 2.75rem 2.5rem 2.6875rem",
display: "flex",
alignItems: "center",
backgroundColor: "#E8F1F6",
borderRadius: "1.5625rem",
},
}}
>
<Box className={styles.modalHeader}>
<DialogTitle
sx={{fontSize: "2rem", fontWeight: "bold"}}
>
내 템플릿 공유하기
</DialogTitle>
<IconButton onClick={onClose}>
<CloseIcon sx={{fontSize: "2rem"}} />
</IconButton>
</Box>
<DialogContent>
{content}
</DialogContent>
<DialogActions>
<Button
className={styles.addBtn}
sx={{
backgroundColor: "#FFFFFF",
borderRadius: "1.5625rem",
fontSize: "2rem",
letterSpacing: "0.02rem",
color: "#739CD4"
}}
onClick={handleShareMemo}
>
추가하기
</Button>
</DialogActions>
</Dialog>
);
};
export default ReadingShareModal;
ReadingShareModal.propTypes = {
open: PropTypes.bool.isRequired, // open은 boolean 타입이고 필수 props
onClose: PropTypes.func.isRequired, // onClose는 함수 타입이고 필수 props
memos: PropTypes.array.isRequired, // memos는 배열 타입이고 필수 props
handleSelectMemo: PropTypes.func.isRequired, // 메모 선택 처리 함수
handleShareMemo: PropTypes.func.isRequired, // 메모 전송 처리 함수
isLoading: PropTypes.bool, // 메모 데이터 로딩
isError: PropTypes.bool, // 메모 데이터 불러오기 에러시
};
이에 따라 상태별로 독서 기록 공유 모달은 조건부 rendering이 되며, 이번 기회를 통해 async와 await의 중요성을 다시 한번 깨달았다.