오늘은 특정 장소에 대한 리뷰를 수정하는 기능을 구현해 보았다. 내배캠에서의 최종 프로젝트인 만큼 기존에 사용해왔던 input
태그나 textarea
태그를 사용 하는 것이 아닌 제대로된 에디터를 사용하고 싶다는 욕심이 있었다.
이것을 프로젝트에 적용하는 데에 이런 저런 난관이 있어서 생각했던 것 보다 훨씬 애먹었지만 그래도 결국에는 현재 진행중인 프로젝트에 적용하는 데에 성공했다.
이유는 간단하다. input 이나 textarea로는 표현할 수있는 형식의 제약이 지나치게 많기 때문이다. 당장 input 태그로는 글이 조금만 길어져도 제대로 표현하기 어렵고, 기본적인 줄바꿈 조차 잘 지원하지 않는데 장소에 대한 리뷰이니만큼 글의 길이가 길어질 가능성이 다분했기 때문이다.
textarea를 사용하면 줄바꿈까지는 무리 없이 표현할 수 있지만 역시나 볼드체나 이탤릭체, 취소선, UL, OL 등을 표현하기는 어려웠다. 지금 글을 작성하고 있는 velog 처럼 글에 다양한 스타일을 쉽게 적용할 수 있으려면 에디터 사용이 필수 였다.
어떤 에디터를 사용해야할지 정하기 위해 다양한 에디터 라이브러리를 검토해 보았고 최종 후보에 남은 것은 토스트 ui 에디터, quill, react-draft-wysiwyg 이었다.
quill 이 사용은 가장 간단해 보였으나 깃헙을 확인해보니 마지막 업데이트가 2년 전으로 한동안 유지보수가 이루어 지지 않은 것으로 판단해 포기하였다.
react-draft-wysiwyg는 문서가 충분히 상세하지 않아 이용하기 어렵겠다고 판단했다. 에디터 사용 자체가 완전히 처음이니 만큼 문서가 최대한 자세하게 작성된 라이브러리를 사용할 필요가 있었다.
그래서 최종적으로 선택한 것이 토스트 ui 에디터이다. nhn 에서 개발한 라이브러리로 공식문서가 자세히 작성되어 있었고, 한국인 개발자들이 많이 사용하는 만큼 한국어로 된 레퍼런스를 찾는것도 쉬웠다. WYSIWYG 방식을 지원하는 것도 토스트 ui 에디터를 선택한 이유중 하나이다. 개발자들을 대상으로 만든 웹 어플리케이션이 아닌 만큼 일반 사용자들이 마크다운 문법을 알고 있을 것이라고 기대하기 어렵기 때문이다.
const NoSsrEditor = dynamic(() => import('../common/TuiEditor'), {
ssr: false,
});
const NoSsrViewer = dynamic(() => import('../common/TuiViewer'), {
ssr: false,
});
먼저 에디터를 사용할 컴포넌트 함수 밖에서 위와 같이 dynamic import 해준다. 이는 ssr을 하지 않기 위함이다. 에디터는 DOM 에 의존하는데 ssr과정은 서버환경에서 이루어지므로 반드시 에러가 발생하게 된다. 이를 막기 위해 ssr:false
로 설정해두고 클라이언트 사이드에서 로딩 되도록 하는 것이다. 이에 대해서는 Lazy Loading 을 다룬 Next.js 문서에서 확인할 수 있다.
return (
<>
{!isEditing && <NoSsrViewer content={content} />}
{isEditing && (
<form onSubmit={editDoneButtonHandler}>
<NoSsrEditor content={content} editorRef={ref} />
<Button type='submit'>수정완료</Button>
</form>
)}
(후략)
tsx 리턴부에서 isEditing 상태에 따라 조건부 렌더링 되도록 했다. 수정 모드일때는 에디터가 표시되고, 수정 모드가 아닐 때에는 뷰어가 표시 된다.
개발중 수정이 완료되고 수정사항이 실제로 서버로 정상적으로 들어왔음에도 화면이 리렌더링 되지 않는 문제가 발생해서 한참을 고생했는데, 이부분은 mutation에 낙관적 업데이트를 적용하면서 해결하였다.
const updateReviewMutate = useMutation({
mutationFn: updateReviewContent,
onMutate: async (updateReviewParams) => {
await queryClient.cancelQueries({ queryKey: ['review', id] });
const prevReview: object | undefined = queryClient.getQueryData([
'review',
id,
]);
const updatedReview = {
...prevReview,
content: updateReviewParams.editValue,
};
queryClient.setQueryData(['review', id], updatedReview);
return { prevReview };
},
onError: (error, updateReviewParams, context) => {
if (context?.prevReview) {
queryClient.setQueryData(['review', id], context.prevReview);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['review', id] });
setIsEditing(false);
},
});
위 코드가 리뷰의 수정을 담당하는 코드이다. updateReviewParams
는 mutationFn 에 전달할 인자가 들어있으며 여기에는 '수정된 내용' 도 포함된다. 이 '수정된 내용'을 일단 setQueryData
에 전달하여 낙관적 업데이트를 실행하고 에러가 발생했을때만 수정 되기 전 내용으로 롤백되도록 하였다. 그리고 마지막으로 onSettled
에서 업데이트가 성공했건 성공하지 못했건 invalidateQueries로 화면과 서버의 내용을 동기화 하고 수정 상태를 false로 변경시키면서 코드가 종료된다.
뷰어를 적용하여 마크다운 형식으로 작성된 string을 작성자가 의도한대로 잘 보여주고 있다.
수정모드에서는 편집기가 열리며 상단의 메뉴를 통해 원하는 글자 스타일링을 쉽게 적용할 수 있다. 수정한 후 '수정 완료' 버튼을 누르면 즉시 수정 사항이 반영된 뷰어로 돌아가게 된다.
아직 세부적인 디자인 시안이 나오지 않아 스타일링은 전혀 적용하지 않고 기능만 만들어둔 상태이지만 디자인 시안이 나오고 이에 맞춘 디자인을 적용하고 나면 훨씬 보기 좋을 것으로 기대한다.