2023. 7. 17. 19:55ㆍ프로젝트
데브코스 1차 프로젝트가 끝이 났다. 아니, 마감 기한만 끝났지, 앞으로 계속 수정이 필요하다.
전반적으로 느낀 점은, 아직은 나한테 설계가 중요하다. 일단 해볼까? 하면서 생각없이 흘러가는 대로 짜면 얼렁뚱땅 성공을 하더라도 크게 도움이 되지 않을 것이다. 이번 프로젝트를 하면서 모든 요구사항들을 고려하여 초반 설계를 탄탄히 해놓는 것이 중요하다는 것을 느꼈다.
프로젝트를 하면서 여러 번 난관을 마주했다.
이것도 다 설계가 미흡해서 그런 것 같다. 만약 초반에 이런 요구사항들까지 생각을 하면서 설계를 했다면, 개발 중간 중간 막히는 일이 덜하지 않았을까..
그래서 이번 글은 현재까지 개발한 프로젝트의 동작 원리 및 구조와, 개발하며 어떤 난관을 마주했는지, 앞으로 어떤 수정이 더 필요할지를 정리해보고자 한다.
프로젝트 소개
프로젝트는 여기서 확인하실 수 있습니다.
바닐라 JS로 노션을 클론코딩하는 과제였다.
구현한 요구사항들은 다음과 같다.
- History API를 사용하여 하나의 페이지만 사용하는 SPA의 형태이다.
- /documents/{documentId} 일 때는 해당 문서의 편집 화면이 렌더링되고,
- 루트 URL ( / ) 일 때는 편집 화면이 아닌 기본 화면만 렌더링된다.
- 사이드바는 항상 좌측에 고정되어 노출된다.
- 노션 문서들을 트리 구조로 사이드바에 렌더링을 하고,
- 문서 아이템을 클릭하면 URL을 변경한 후, 해당 문서가 우측 편집기 컴포넌트에 렌더링이 된다.
프로젝트 구조
좌측 사이드바는 src/components/sideBar, 우측 편집기는 src/components/content 에 각각의 컴포넌트들을 모아놓았다.
App.js
let timer = null;
const sideBar = new SideBar({ $target });
const content = new DocumentContent({
$target,
initialState: null,
onEditing: (document, id) => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(async () => {
if (id) {
await request(`/documents/${id}`, {
method: "PUT",
body: JSON.stringify(document),
});
}
}, 2000);
},
});
SideBar와 DocumentContent를 불러온다.
Editor에서 호출될 onEditing 이벤트 리스너는 사용자가 제목 및 내용을 수정할 때 발생하는 키 이벤트에 대한 이벤트 리스너인데,
이를 App.js에서 정의하였다.
Route 과정
export const push = (nextUrl) => {
window.dispatchEvent(
new CustomEvent(ROUTE_CHANGE_EVENT_NAME, {
detail: { nextUrl },
})
);
};
push 함수를 만들었다. 이 함수를 호출하면 dispatchEvent
를 통해서 커스텀 이벤트를 발생시킨다.
export const initRouter = (onRoute) => {
window.addEventListener(ROUTE_CHANGE_EVENT_NAME, (e) => {
const { nextUrl } = e.detail;
if (nextUrl) {
history.pushState(null, null, nextUrl);
onRoute();
}
});
};
initRouter함수를 App.js에서 호출한다. 따라서 push함수를 호출하여 이벤트가 발생하면 해당 이벤트에 대한 이벤트리스너가 실행된다. 즉, push함수를 호출하며 설정했던 nextUrl
을 가지고 해당 url로 history.pushState()를 통해 url을 history API에 추가되고, initRouter에 인수로 설정했던 onRoute 콜백함수가 호출된다.
App.js에서 initRouter를 호출하면서 설정했던 콜백함수는 다음과 같다.
this.route = async () => {
$target.innerHTML = "";
const { pathname } = window.location;
const rootDocuments = await request("/documents");
if (pathname === "/") {
sideBar.setState(rootDocuments);
new Default({ $target });
} else if (pathname.indexOf("/documents/") === 0) {
const [, , documentId] = pathname.split("/");
// TODO: 우측 편집기 초기화
sideBar.setState(rootDocuments);
content.setId(documentId);
}
};
- 우선 전체 페이지를 싹 다 비워 초기화한다.
- 다음 현재 URL의 pathname에 따라 렌더링을 다르게 하는데, 이때
a./
인 경우 루트 페이지로 우측에는 편집기가 아닌 기본 환영 멘트를 띄운다.
b./documents/~
인 경우 특정 게시글을 연 상태이므로 우측에 편집기를 띄운다.
이때 content.setId(documentId)의 코드를 보면, DocumentContent 컴포넌트의 setState를 호출하여 리렌더링을 하고 있다. Document의 setState함수는 다음과 같다.
this.setId = async (nextId) => {
this.id = nextId;
await fetchData(); // this.id에 해당하는 도큐먼트를 서버로부터 가져옵니다.
this.render();
};
fetchData는 도큐먼트를 서버로부터 받아온 후에 Editor컴포넌트를 setState하여 리렌더링한다.
첫 번째 난관...
내가 마주했던 가장 큰 문제점은, 사이드바의 목록에서 도큐먼트 아이템을 클릭할 때마다 화면 전체가 '깜빡'이면서 렌더링이 되는 현상이었다.
위에 정리해 둔 Route 동작 원리를 따르면 도큐먼트 아이템을 클릭하면 push(아이템 id)를 호출하여 URL이 변경되고, App.js에서 다시 route 콜백이 호출되어 변경된 URL에 따른 페이지 렌더링이 실행된다. 그렇다면 route 콜백 함수 내의 sideBar.setState()를 호출하는 과정에서 사이드바까지 깜빡이면서 리렌더링이 되는 문제를 해결해야 한다.
SideBar 컴포넌트에서의 setState 함수는 자기 자신의 state를 업데이트 시켜줌과 동시에 DocumentList 컴포넌트의 setState를 호출하면서 그 컴포넌트의 state도 업데이트 시킨다.
(수정 중)
두 번째 난관...
원래 목적은 우측 편집기에서 제목을 수정하면 사이드바에서도 해당 제목이 실시간으로 동시에 바뀌는 것을 원했다.
이벤트가 발생할 때마다 DocumentListItem의 setState를 호출하여 e.target.value
를 인수로 던져주면 제목이 동시에 바뀌지 않을까? 생각했다. PUT을 날리고, 다시 rootDocuments를 GET한 다음에 모든 사이드바 아이템을 리렌더링하는 방식도 있겠지만,
1) PUT요청을 보내는 이벤트리스너에 디바운스를 걸어주었기 때문에 제목이 실시간으로 바뀌지 않을 것이다.
2) 디바운스가 아니더라도 서버에 요청을 두 번 보내는 시간이 소요되어 사용자 입장에서는 약간의 시차를 느낄 수 있다.
이러한 이유로 낙관적 업데이트를 통해 요청을 기다리지 않고 바로 e.target.value
로 제목을 업데이트시켜주는 방식을 생각했다.
시도한 해결 방법
이를 구현하기 위해서는 편집기에서 키보드 이벤트가 발생할 때마다 호출되는 onEditing
이벤트 리스너에서 SideBar 컴포넌트도 조작을 할 수 있어야 하므로 이를 상위 컴포넌트인 App.js
에 정의했다.
onEditing: debounce(async (document, id) => {
if (id) {
updateDocument(id, document);
}
}, 500),
그러나 위 코드에서 알 수 있듯 onEditing함수는 document를 인수로 받아 디바운스를 통해 업데이트 api를 호출하는 방식으로 작동하는데, 이 함수를 사용해서 타깃 도큐먼트를 사이드바에서 업데이트시키기에는 쉽지 않았다. 무엇보다 그렇게 하면 하나의 함수가 여러 역할을 하게 되는 거고, title인수도 추가로 받아와야 해서 코드가 번잡해지는 문제가 있었다.
여기서 멘토님께서 '아!'하게 되는 힌트를 주셨다.
보자마자 별도의 onChangeTitle
이벤트리스너를 App.js에서 생성해 Editor 컴포넌트에서 이벤트 발생 시 호출하도록 했다. 매개변수로는 수정하고 있는 도큐먼트의 id와 수정된 title (e.target.value
)이다.
// Editor.js
$editor.querySelector("[name=title]").addEventListener("keyup", (e) => {
const nextState = {
...this.state,
title: e.target.value,
};
this.setState(nextState);
onEditing(this.state, this.id);
onChangeTitle(this.state.title, this.id);
});
// App.js
onChangeTitle: (title, id) => {
const newRootDocuments = JSON.parse(JSON.stringify(sideBar.state));
const targetIndex = newRootDocuments.findIndex((elem) => elem.id === id);
if (targetIndex !== -1) {
newRootDocuments[targetIndex] = {
...newRootDocuments[targetIndex],
title,
};
}
sideBar.setState(newRootDocuments);
},
우선 SideBar에 렌더링될 루트 도큐먼트들을 복사해두고, 그 도큐먼트들 중에서 id
가 동일한 원소(도큐먼트)의 title
을 수정해주었다. 이렇게 수정을 거친 새로운 루트 도큐먼트들 newRootDocuments
을 sideBar 컴포넌트의 state로 업데이트해줌으로써 낙관적 업데이트에 🎊성공🎊했다.
결론: 사이드바 루트 도큐먼트에 대한 별도 api 호출 없이 낙관적 업데이트를 통해 제목이 실시간으로 바뀌도록 하였다.
개선이 필요한 점
[ ] 사이드바 아이템 각각에 대해 이벤트리스너를 모두 달지 않고 리스트 전체에 대해서 이벤트리스너 하나를 달도록 수정이 필요하다.
[ ] 첫 번째 난관 (페이지 변경 시 사이드바까지 깜빡이며 리렌더링되는 문제)에 대한 해결. 원인을 찾아야 한다.
'프로젝트' 카테고리의 다른 글
복잡한 퍼널 관리하기 (React Context API + Custom Hook) (2) | 2024.09.13 |
---|---|
React의 useTransition으로 UI block 현상 해결하기 (0) | 2024.04.01 |