토이 프로젝트 [Ducky] 회고
지난번 단어장 웹에 이어서 이번에는 더키라는 블로그 웹을 만들었다.
더 큰 프로젝트다 보니 많은 리액트 라이브러리를 사용했다. 물론 별도의 라이브러리 없이 직접 만들면 좋겠지만 나는 편리하고 좋은 라이브러리를 찾아서 사용하는 것도 좋은 방법이라 생각한다. 미천한 나의 실력으로 직접 만든 불안정한 라이브러리를 사용하여 사이드 이슈를 많이 발생시키는 것보다 많은 사용 하며업데이트고 관리되는 라이브러리를 찾아 사용하는 것도 프로젝트를 구현하는 데 있어서 좋은 방법이라 생각한다. (구차한 변명 같네ㅎ)
이번 프로젝트 개발 기간은 ( 라이브러리를 많이 써서 그런가 ) 이전보다 짧은 총 6일 걸렸다(첫 커밋 ~ 마지막 커밋). 아무래도 이전 프로젝트에서 파이어베이스 인증을 제외한 간단한 쿼리 사용법에 대해 배웠다 보니 이번에는 좀 더 수월히 개발할 수 있었다.
# 구상
이번에도 종이에 큰 그림만 끄적였다. 처음 메인 화면과 생성할 메뉴 "저장소", "글감" 두 가지만 만들기로 결정했다. 나중에 "글감"은 Post 게시물로 이름을 변경했다. 그리고 메인화면은 사실 트위터나 Reddit 같은 화면으로 구현하려고 했는데, 모든 페이지가 글(게시물)을 작성하는 페이지다 보니 굳이 넣어야 하나 싶어서 제외했다. ( 사진은... 커피를 쏟아서 없다...😭 )
# 라이브러리
- Typescript
- Shadcn/ui 🎨
- TailwindCSS 🎨
- Zustand 💾
- TanStack (React Query) 💾
- Blocknote ✍️ : 노션처럼 생긴 깔끔한 에디터이다. 아직 베타버전이지만 사용하는데 문제없다.
- Zod : 타입스크립트 스키마 정의. CUD 작업시 전달 타입 스키마 정의할 때 사용.
- Sonner : toast 메시지 표시.
- hello-pangea/dnd : 드래그 앤 드롭 라이브러리. 칸반보드에서 카드나 리스트 옮길 때 사용.
- usehooks-ts : outside 클릭 이벤트 훅(useOnClickOutside)을 사용.
# 고충
크게 기억나는 몇 가지만 적어보았다.
🔥 Firebase
하위 컬렉션은 조회가 안된다는 것을 알았기 때문에 디비 구조를 정하는 데 있어서 많은 고민은 없었다.
1) 사용자 인증 정보 조회 문제
사용자 인증 부분은 경험이 없어서 firebase authentication 부분 정보를 많이 찾아봤다. 로그인한 후 유저 정보를 갖고 올 때 `auth.currentUser` 로 로그인한 유저 정보를 갖고 올 수 있는데 파이어베이스 매서드를 이용하는 것이라 매번 firebase에 조회를 하면 파이어베이스 조회 수가 증가하여 금전적으로 문제가 생길 것 같았다. (물론 아무도 접속 안하겠지만^^)
유저 정보를 zustand와 같은 상태 관리 라이브러리에 저장할까 생각했지만, 리랜더링될 때 정보가 사라지기 때문에 이는 맞지 않았다. 더 찾아보니 파이어베이스는 로그인을 하면 인증 정보를 사용자의 캐시에 저장하고 그 정보를 갖고 오는 것이라고 한다. 그래서 auth.currentUser를 사용했다.
2) 사용자 탈퇴
- 문서 : 사용자 삭제하기
삭제하려는 사용자가 작성한 코멘트나 게시물들을 모두 다른 이름으로 바꾸고, 계정을 삭제하려고 아래 명령어를 입력하니 오류가 발생했다. (auth/requires-recent-login)
const user = auth.currentUser;
await user?.delete();
사용자 계정 삭제, 비밀번호 변경과 같은 민간한 작업을 하려면 로그인한 적이 있어야 한다고 한다. (공식문서)
하지만 사용자 삭제는 대부분 로그인한 유저만 탈퇴(삭제)가 가능하기 때문에 이미 로그인되어 있는데 왜 안되지?싶다. 공식문서상에는 재인증을 하라고 했기 때문에 재인증 기능을 로그인할 때 넣어줬다.
왜 이곳에 넣었냐면, auth.currentUser에는 사용자의 패스워드 정보가 없기 때문에 인증정보를 갖고 올 수 없다. 그래서 이미 로그인 이후에는 재인증이 불가능하기 때문에 로그인할 때 바로 재인증하도록 하였다.
3) 배열 필드 요소 변경
저장소 페이지에서 리스트와 카드의 관계를 어떻게 구현할 지 가장 큰 고민이었다. 파이어베이스에서 하위 컬렉션 접근이 안된다는 것을 알았기 때문에 리스트(kanban)에 cards라는 배열 필드를 추가하고, 카드가 추가되면 cards에 추가하는 형식으로 구현했다.
근데 이 방식의 단점이 있었다. 배열 update 기능을 지원하지 않기 때문에 카드 정보가 변경되면 해당 카드만 찾아서 변경이 불가능했다. 문서에서는 기존 데이터를 삭제하고 추가하는 방식으로 하라고 설명되어 있었다. (공식문서) 게다가 배열에서의 삭제는 값이 정확히 일치해야만 삭제가 되기 때문에 트랙젝션을 걸고 리스트에서 변경하려는 카드 정보를 조회한 후 카드를 삭제하고 새 카드를 추가하는 방식으로 카드를 업데이트했다.
await runTransaction(db, async (transaction) => {
// 리스트 조회
const listRef = doc(db, "kanban", listId);
const listDoc = await transaction.get(listRef);
// 현재 카드 정보 조회
const curCard = listDoc.get("cards").find((card: Card) => card.id === id);
// 변경된 새 카드
const newCard = { ...curCard, ...values };
transaction.update(listRef, { cards: arrayRemove(curCard) }); // 현재 카드 삭제
transaction.update(listRef, { cards: arrayUnion(newCard) }); // 현재 카드 추가
card = newCard;
});
🤨 실시간으로 변경된 정보 갖고 오기
내가 만든 저장소 페이지는 여러 명의 사람이 동시에 작업할 수 있기 때문에 실시간으로 변경되는 정보를 갖고 올 수 있어야 했다. 파이어베이스에는 감사하게도 `onSnapshot`이라는 메서드를 이용해서 실시간으로 변경되는 정보를 갖고 올 수 있다. 그래서 아래와 같이 구현을했다.
const [lists, setLists] = useState<List[]>([]);
// 변경된 리스트/카드 정보 갖고오기
const fetchData = useCallback(() => {
const q = query(
collection(db, "kanban"),
where("boardId", "==", "ducky"),
orderBy("order", "asc")
);
const unsubscribe = onSnapshot(q, (doc) => {
const data = doc.docs.map(
(item) => ({ ...item.data(), id: item.id } as List)
);
setLists(data);
});
return unsubscribe;
}, []);
useEffect(() => {
const unsubscribe = fetchData();
return () => unsubscribe();
}, []);
하지만, 일반적인 회사에서는 파이어베이스보단 자체적인 백엔드 서버에 디비를 두고 REST API를 통한 통신을 많이 하기 때문에 이 방법이 아닌 다른 방법으로는 어떻게 할까 고민을 해봤다.
폴링(polling)
주기적으로 서버에 API를 호출하는 것이다. 예전에 회사에서 새로운 메세지가 왔을 때 표시하기 위해 setInterval로 3초마다 서버에서 데이터를 갖고 오도록 구현한 적이 있다. TanStack 에서는 어떻게 구현하는지 궁금해서 찾아보니 useQuery에 `refetchInterval`이라고 있었다. 이걸 사용하면 될것 같다.
추가로, 웹 소켓 방식도 이용할 수 있을 것 같다. 네트워크 소켓 통신에 대한 내 지식이 짧아서 잘 모르겠다.
⌨️ Input에 한글 입력시 두 번 전송되는 오류
코멘트 작성 시 Enter 키를 입력하면 자동으로 코멘트가 생성되도록 구현했다. 하지만 아래 영상을 보면 한글을 입력하면 두 번 전송된다.
Save 버튼을 눌러 코멘트를 전송하는 것은 문제 없이 한번만 생성되지만 Input 안에서 Keydown Enter로 전송하면 두 번 전송되었다. 그것도 영어는 말고 한글만. 잠시 이 오류는 Save 버튼으로만 코멘트를 생성할 수 있도록 막아두고(눈감고🙈) 다른 페이지 작업을 하고 있었는데 InputTags컴포넌트에서도 한글 입력 시 마지막 글자로 태그가 또 생성되는 문제가 발생했다! "이거 같은 원인이겠구나 고쳐야 겠다"라고 다짐했다.
원인을 찾아보니 React만의 문제는 아니고 자바스크립트의 문제였다. [관련 기사] 한글은 영어와는 다르게 한 글자가 자음과 모음 조합으로 구성되어 있다. 그래서 우리가 한글를 입력했을 때 글자가 끝난 건지 컴퓨터는 알 수 없기 때문에 IsComposing (조합중)이라는 속성 값이 true로 표시된다. 따라서 keydown 이벤트 발생했을 때 조합 중인 경우 아무것도 처리 하지 말라고 아래와 같이 조건을 추가하면 된다.
target.addEventListener("keydown", event => {
if (event.isComposing || event.keyCode === 229) return;
// do something...
});
리액트에서는 event 객체 안에 isComposing 이라는 값이 없다. 왜 인지 찾아보니, 우리가 JSX 에서 컴포넌트에 on[이벤트]로 등록한 이벤트 핸들러는 실제 브라우저의 고유 이벤트와 동일한 동작을 하지만 조금 다르다. 리액트에서의 이벤트는 합성 이벤트(SynthethicEvent)라고 불리며 이 이벤트들은 이벤트 버블링 단계에서 호출된다. (관련 문서) 즉, SynthethicEvent는 동일한 인터페이스를 갖고 있을 뿐 같은 브라우저 DOM 이벤트와 같은 객체가 아니다! 그래서 브라우저의 돔 이벤트를 막기 위해서는 event.nativeEvent를 이용하면 된다.
export default function CommentInput() {
const onTextareaKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return;
// do something...
}
return (
<textarea onKeyDown={onTextareaKeyDown} />
)
}
# 느낀 점
ȸ TanStack
리스트나 상세 페이지와 같이 서버에서 갖고 온 데이터를 캐싱하여 사용하면 좋을 곳은 TanStack 쿼리를 이용해서 데이터를 갖고 오도록 했다.
그리고 게시물 쪽에는 리스트는 이번에도 useInfiniteQuery를 이용해서 무한 스크롤 방식으로 구현했고, 게시물 생성/수정 시 useMutation를 처음 사용해보았다. 나는 다른 페이지에서는 useMutation이 내가 만든 useAction 커스텀 훅과 같은 기능이기 때문에 useMutation을 쉽게 적용해서 사용할 수 있었다.
🦾 리액트 최적화
이 부분은 사실, 현재 코드에서 더 최적화가 가능한지 모르겠다. 모든 개발자가 고민하는 부분이 아닌가 싶다. 추후에 진짜 프로젝트에 사용해보면서 코드 리뷰 받아보면 좀 더 좋아지지 않을까?😊
로딩화면 같은 경우 API 호출할 때 마다 보여지기 때문에 계속 재사용되기에 루트 페이지(레이아웃)에 넣고 zustand로 전역에서 show/hide 할까 고민 했고, 로딩 컴포넌트를 createPortal을 이용해서 div#root 밑에 넣어줬다. 그러면서 로딩이 필요한 컴포넌트에서는 로딩 컴포넌트를 추가해서 조작하도록 했다.
{isLoading && <LoadingSpinner />}
createPortal은 위치만 분리되어 있을 뿐 부모 컴포넌트에 영향을 받지만, 내 로딩 컴포넌트는 상태나 Prop이 따로 없기에 부모 컴포넌트가 리랜더링 되더라도 리렌더링되지 않기에 괜찮다고 생각했다. 물론 리랜더링 방지로 React.memo도 있지만 createPortal로 선언하면 DOM 구조에서 항상 밑에 보여지므로 더 좋은 선택인 것 같다.
𝑻 타입스크립트
이번에는 CUD(생성,수정,삭제)시 zod를 통해 타입을 검증하도록 구현해보았다. 유튜브 보다가 알게된 라이브러리인데, 이걸 사용하면 전달되는 데이터의 유효성 검증도 가능하기 때문에 API에 전달하기 전 데이터가 올바른지 체크할 수 있어서 좋았다.
👔 스타일
이번에도 shadcn/ui 와 tailwindCSS 를 이용하였다. 반응형, 테마에 따른 설정도 해야하기 때문에 클래스가 매우 많이 길어서 JSX 코드를 보기에는 많이 불편하지만 스타일 클래스들에 익숙해지니 크게 신경은 안 쓰였다.(안쓰였다고 생각하자...🥲)
지난 프로젝트에서는 css moudle도 사용해서 세세한 작업을 진행하였지만 이번에는 TailwindCSS만으로 해결했다.
# 결론
지난번 프로젝트에 비해 좀 많이 커진 블로그 웹사이트다 보니 시간이 오래 걸릴 줄 알았는데, 라이브러리를 많이 사용해서 그런지 생각보다 시간이 많이 투자되진 않았다. 웹에 대한 경험은 있기에 "이렇게 구현하면 될 것 같은데, 무슨 메소드를 사용하면 되지?", "이 명령어 쓰면 될 것 같은데 어떻게 썼었지?" 하고 구글링을 많이 해보는 편이지만 해법을 찾는데 시간이 그리 많이 소요되는 편은 아닌 것 같다. 물론 방법을 못찾아서 우회하기도 한다. 그러면서 커가는게 아닐까...?🐣
또 다른 프로젝트를 한다면 그때는 Spring Boot 를 이용해서 REST API를 만들고 프론트로는 React를 사용하는 형식의 프로젝트를 진행해 봐야겠다. 리액트를 배워가면서 점점 자바가 머릿속에서 잊혀져가는 중이다... 😢
👍 잘한 점 3가지
- 파이어베이스 인증부분까지 경험 해봤다. 🎉
- 구현 방식에 대해 좀 더 다양하게 생각해보려고 노력했다.
- Theme 변경 기능을 위해 next/useTheme 훅을 분석 후 useTheme 커스텀 훅을 만들어서 적용한 점.
- 시스템 테마를 기본으로 적용하고 LocalStorage에 저장하도록 했다.
👎 아쉬운 점 2가지
- 테스트 코드를 작성하면서 개발하지 않은 점. ( 아 이번에도 못했다🥲 )
- 한 메서드에 너무 많은 기능을 넣은 것 같다. if-else 너무 사용한 것 같아 복잡해 보인다. 변수/메서드 명 잘 짓자! - 너무 클린하지 못한 코드 🧹
제가 작성한 코드에 코드 리뷰(평가)도 받고 싶은데 언제든지 제 깃헙에 남겨주세요! 환영입니다! 🫶