[프로젝트 회고] 노션 클론 w/ VanillaJS

에구마·2023년 10월 30일
1
post-thumbnail

첫 프로젝트인 '노션 클론 프로젝트'를 마무리했다!
주말 포함 열흘 동안 개발하고 이후 열흘 동안 리뷰 및 수정보완을 진행한 프로젝트를 돌아보며 회고하려고 한다!

🔗 자체제작 노션 🔗


많은 피드백 남겨주세요😳


개발 과정

1. 설계

코딩 시작 전 전반적인 설계를 열심히 했다. 시간도 오래들이고 전체적인 흐름을 완전히 이해하는데에 많은 노력을 했다.

🎯 목표한 것
실습을 할 때 내가 느낀 부족함과 불편함은 컴포넌트 간에 전달되는 인자들이 무엇인지를 온전히 이해하지 못했던 점이었다. 그래서 컴포넌트 구조 뿐만 아니라 어떤 전달인자가 왜 필요한지, 어떻게 쓰일지에 대해서 적고 또 적으면서 이해했다.
🌱 코드 상에서 주석으로 적어두기도 했지만, 커밋할 때 PR을 고려하여 주석을 삭제하는 것에 유의하자! (당장 내가 알아보는 것도 중요하기에 남겨두는것 vs 깔끔하게 커밋하는 것의 습관화 사이에서 아직 딜레마를 겪고 있다..)

컴포넌트 구조는 다음과 같이 구상하였다.

2. 구현

API를 통해 Documents 렌더링

api호출부는 함수화하였다. /utils 폴더를 만들어 함수와 상수들을 저장했다.

export const API_END_POINT = "";
export const request = async (url, options = {}) => {
  try {
    const res = await fetch(`${API_END_POINT}${url}`, {
      ...options,
      headers: { "Content-Type": "application/json", "x-username": "" },
    });
    if (res.ok) {
      return await res.json();
    } else {
      throw new Error("API 처리 중 오류");
    }
  } catch (e) {
    console.log(e);
  }
};
  • "Content-Type": "application/json"
    서버에서 데이터를 인식하기 위해선 Json형식이 필요하다. 그래서 request에 실어 보내는 데이터(body)의 타입을 이렇게 json으로 지정해줘야한다.

history 라우팅 + 커스텀 이벤트

라우트 발생시 호출할 특정 키값을 이벤트 타입으로 한 커스텀 이벤트 리스너를 생성한다.
커스텀 이벤트가 필요한가?
어느 컴포넌트에서 url 변경이 일어날 시 최상위인 App의 재렌더가 필요하다. 재렌더하는 함수인 setState 등을 자식 컴포넌트에 props drilling을 만들면서 전해주기 보다 커스텀 이벤트를 사용해서 전역에서 접근할 수 있다.

export const initRouter = (onRoute) => {
  window.addEventListener(ROUTE_CHANGE_EVENT_NAME, (e) => {
    const { nextUrl } = e.detail;
    if (nextUrl) {
      history.pushState(null, null, nextUrl);
      onRoute();
    }
  });
};
  • history.pushState 세번째 인자를 history url에 추가한다.
  • 인자인 onRoute에는 현재 url주소에 따라 호출하거나 재렌더링할 컴포넌트의 작동을 지정한다.

해당 이벤트를 호출할 땐, dispatchEvent를 사용할 수 있다. 매번 이를 사용하여 호출할 수도 있지만 반복을 줄이기 위해 이동할 url만을 인자로 주며 커스텀이벤트를 작동시킬 함수도 만들어둔다.

export const pushRoute = (nextUrl) => {
  window.dispatchEvent(
    new CustomEvent(ROUTE_CHANGE_EVENT_NAME, {
      detail: {
        nextUrl,
      },
    })
  );
};

SideBarList

노션을 보면 다음과 같이 상-하위 document의 트리 형태를 볼 수 있다. 요소를 보면 div>div>div> .. 의 구조였다.

직관적으로 이해하기 위해서 ul>li구조를 사용하였다. 상위 ul에 직속 하위 만큼 li를 붙이고 각각의 li에 대해 이 과정을 반복한다.

☄️ 이슈다 이슈!
해당 페이지를 열기 위해서 li영역을 클릭해야 하는데, li>ul>li.. 구조로 인해서 하위 페이지의 영역을 클릭해도 상위li영역으로 인식하는 문제가 있었다.. 또한 페이지 제목 왼쪽의 빈 영역을 눌러도 그 페이지로 인식해야 하지만 ul,li의 패딩으로 주어져있기 때문에 인식하지 않는 문제.

  • 트러블 슈팅 1)
    모두 div태그로 전환
    => 한계 : ul>li 구조로 토글 여닫기를 구현하고자 해서 불가능했다.
  • 트러블 슈팅 2) ✔️
    li 내부 요소들을 div로 감싸고 position과 left값을 이용하여 클릭 가능한 영역을 조절한다.
    const $subUl = document.createElement("ul");
    const padding = $target.style.paddingLeft;
    $subUl.style.position = "relative";
    $subUl.style.paddingLeft = padding ? Number(padding.split("px")[0]) + 40 + "px" : "0px";
    $subUl.style.right = padding ? padding : "0px";
    $target.appendChild($subUl);
    documents.map((doc) => {
      const $subLi = document.createElement("li");
      $subLi.style.position = "relative";
      $subLi.style.paddingLeft = padding ? Number(padding.split("px")[0]) + 40 + "px" : "0px";
      $subLi.style.right = padding ? Number(padding.split("px")[0]) + 40 + "px" : "0px";
      $subLi.dataset.id = doc.id;
      $subLi.innerHTML = `<div class="each" style="position: relative;padding-left: inherit;right: inherit; width:200px">
        <button class="toggle_button">⩥</button>
        <span>${doc.title}</span>
        <button class="new_button">➕</button></div>
      `;
      $subUl.appendChild($subLi);
      if (doc.documents.length > 0) {
        const newDocuments = doc.documents;

코드가 너무 번잡해서 최선일지에 대한 의문이 든다..


지속적으로 서버에 저장 (디바운스)

추가적인 '저장'버튼 없이 사용자의 입력에 따라 지속적으로 서버에 업데이트가 필요하다. 하지만, 매 키 입력마다 서버에 요청을 보내는 것은 너무 과부하다! 그래서 디바운스를 이용하였다.

onEditing: async (nextState) => {
// 디바운스
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(async () => {
    localStorageSetItem(DOC_TMP_KEY, {
      ...nextState,
      updatedAt: new Date(),
    });
  }, 1000);
},

☄️ 이슈다 이슈!
에디터에서 제목을 바꾸는 경우엔 SideBarList도 업데이트가 되어야한다. 이를 위해 커스텀이벤트를 설정해두었다.
그런데 위처럼 디바운스를 주게되면 "제목 수정 -> 커스텀 이벤트가 먼저 실행 되어 GET 요청 -> 디바운스 1초 후 변경된 제목 POST' 순서가 되어 변경한 제목을 반영시키며 렌더링할 수가 없었다!

  • 트러블 슈팅 1) 0초로 해도 setTimeout은 WebAPI라 콜스택에 늦게 쌓인다. 즉시실행함수를 이용하자.
    -> 순서에 변화는 없다
  • 트러블 슈팅 2) ✔️
    async await로 비동기처리를 하자!!
  $editorTitle.addEventListener("keyup", async (e) => {
	// 생략
    await onEditing(nextState, "title");

    window.dispatchEvent(new CustomEvent("render-SideBarList"));

onEditing 콜백을 실행하여 PUT요청을 마친 이후 커스텀이벤트로 GET요청을 실행한다.



SubPages

현재 페이지에 대한 정보를 가지고 하위 페이지를 알 수 있다. 페이지의 id를 이용하여 하위 페이지로 이동할 수 있는 링크를 만들어준다.

contentEditable

일반 텍스트 형태로 입력만 받는 < input>의 활용은 제한적이다!
입력 내에서 태그를 사용하거나, 이미 < h2>등의 태그로 되어있는 내용에 편집도 할 수 있게 하려면? contentEditable 속성값을 이용하자!

<div contenteditable=“true”>

Input이나 textarea와 다르게 값이 inner로 들어가기 때문에 e.target.value로 접근하지 않는다!

🌱 contentEditable속성값은 상속된다! 즉 부모가 contentEditable 하면 자식에서도 contentEditable이다.

본문에도 적용해보기 !

  • 시도 1)
    render에서 innerHTML로 그리기 전에 본문에 대해서 Rich작업을 거쳐보자
    입력값을 바꾸면 setState가 일어나고 setState는 내부에서 state갱신 후 render를 호출한다. 그러면 다시 innerHTML을 그리기 때문에 커서가 빠진다 ㅠ
    ⇒ 커서를 강제로 두려면 .focus()
    -> 강제한 커서가 맨앞에 존재한다
    ⇒ 맨뒤로 두려면 다음과 같은 로직이 필요하다
  const range = document.createRange();
  range.selectNodeContents($editor.querySelector(".editor_content"));
  range.collapse(false);
  var sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
  • .
    -> 이젠 맨뒤에만 존재한다 (ㅋㅋㅋ)
    ⇒ 지금 커서가 가리키는 곳을 기억해두어야한다!
    여기까지 수정을 거치다가 근본적인 다른 방법을 떠올렸다.

  • 시도 2) 저장할때부터 태그로 바꾸자!
    아이디어1이 마크다운으로 저장을 하고 그릴때 태그로 바꿨다면, 애초에 입력을 감지해서 태그로 저장해보자.
    엔터가 입력되면 해당 줄의 텍스트가 ##으로 시작하는지 확인하고, 시작한다면 h2태그로, 아니라면 div태그로 감싸고 PUSH한다.
    ⇒ 방향키 이동에 제한이 있었고, 바로 화면에 반영이되지 않았다.
    다시 생각하면서 엎으려다가, 팀원분이 그러기엔 아깝다하셔서,,,, 다시 머리를 벅벅
  • 시도 2+) 저장할때부터 태그로 바꾸자! ✔️
    ##가 입력되면 입력창을 < div>에서 < h2>로 바꿔주는 형식!
    로직을 정리하자면,
    - 엔터키를 치면 새로운 입력줄이 생겨야 한다.
    - 새로운 입력줄인 newEditor를 div로 만들고 contenteditable속성 및 이벤트 핸들러 또한 지정해준다!
    - 엔터키 입력이 일어난 현재 요소와 같은 레벨로 추가해준다
    - 새로만든 그 입력줄에 focus를 줘야한다.
    - 뒤로가기가 입력되었는데 현재 InnerHTML이 비었으면 그 줄 없애고 위로 가야한다.
    - previousElementSibling를 찾고 거기에 focus
    - 그리고선 현재 타겟은 지운다
    - 방향키 인식해서 focus 변경
    - #이 인식되는 경우 h태그로 변경
    - 위의 키들 말고 다른 일반 문자키들이 입력되면
    - 그 입력발생한 타겟의 innerHTML이 # , ## , 등으로 시작한다면
    - 새로운 입력줄을 #갯수에 맞게 h태그로서 생성하면된다!!
    - innerHTML은 #없앤 내용만 넣어주면 되고 after로 붙이고 타겟은 지운다.(왜냐면 현상태는 타겟이div이고 h만 남으면 되는거니까)
  let allHTML = e.currentTarget.parentNode.innerHTML;

  if (e.currentTarget.innerHTML.indexOf("#&nbsp;") === 0) {
    const txt = e.currentTarget.innerHTML.substring(7);
    const newline = document.createElement("h1");
  
    newline.className = "editor_content_block r";
    newline.setAttribute("contenteditable", true);
    newline.addEventListener("keyup", (e) => handleChangeContent(e));
    newline.innerHTML = txt;
    e.currentTarget.after(newline);
    newline.focus();

    allHTML = e.currentTarget.parentNode.innerHTML;
    e.currentTarget.remove();
  }

  const nextState = {
    ...this.state,
    content: allHTML,
  };
  console.log(nextState);
  await onEditing(nextState, "content");
  }

☄️ 이슈다 이슈!

const blocks = document.getElementsByClassName("editor_content_block");
  for (let block of blocks) {
    block.addEventListener("keyup", (e) => handleChangeContent(e));
  }

이렇게 요소들을 찾아서 각각에 이벤트 리스너를 심었다.
근데!? 이렇게 하면 한 페이지 내에서 새로고침을 했을 때 위의 캡쳐처럼 요소들을 못찾는다!!!! 콘솔에 코드를 입력하면 찾는다. 렌더링 순서의 문제일까.

  • 트러블 슈팅 ) ✔️
    일단, 해결방법은 .editor_content_block를 document에서 찾지 않았다.
// $editor.appendChild($editor_content);해놓은 $editor_content에 붙였다.
$editor_content.getElementsByClassName("editor_content_block");


다른 Document로 이동 링크

시작부터 오해한 것...
실제 노션 사이드바에 있는 검색기능처럼, 어느 입력창에 다른 페이지 제목을 검색한 경우 그 페이지로 이동시키는 기능이라고 당연히 생각했다. (대체 왜 ..) 그래서 트라이 검색 구조를 구현해두었고 입력창을 만들어 연결시켰다.

다행히 오해를 알아차렸음이야..
요구사항 명세엔 "편집기 내에서 다른 Document name을 적은 경우" 정도로 명시되어 있었지만, 노션에서의 같은 기능을 생각하여 @로 시작하는 경우에 뒤에오는 문자열로 문서 목록을 탐색하였다.

내가 생각한 로직
-본문에서 @가 입력되는 것을 감지한다
-@입력시 바로 아래 형제로 div요소($linkWrap)를 추가한다.
-@입력시 @다음 요소로 span을 추가하여 이후 입력부터는 span의 텍스트로 담는다.
-엔터,백스페이스,방향키,@가 아닌 모든 일반 문자입력시 위의 $linkWrap요소가 현재 나타나 있는 상태인지에 따라 조건 분기한다.
-나타나있는 상태라면 그 span에 입력이 되어야 하고 그 입력값으로 다른 페이지 제목에 있는지 검색한다.
-만들어둔 트라이 자동완성 기능을 응용하였고, 검색 결과 각각은 페이지 id를 통해 해당 페이지 url로 이동할 수 있어야 한다.
-백스페이스 입력 중 @때문에 생성된 span이거나 이미 링크연결로 바뀐 텍스트라면 한번에 모두 삭제한다.


토글 여닫기

페이지 리스트 옆의 > 버튼을 누르면 하위 ul의 display 스타일 속성을 바꿔주는 형식으로 구현했었는데, 허점이 많다. 우선 여닫음 상태를 기억하지 못한다.
그래서 로컬스토리지를 이용하였다.

처음 페이지 생성시 로컬스토리지에 페이지 정보 입력과 함께 {open:false}를 추가하였다.
이후에 SideBarList 생성시에 이 값을 확인하며 하위 페이지 표시 여부를 정한다.
토글 버튼을 누르면 로컬스토리지의 open값을 바꿔줘야 하는데 그러기 위해선 페이지 Id를 알아야한다. 이벤트는 버튼에 발생하지만 closest 등을 이용하여 페이지 id를 dataset 속성으로 가지는 li태그에 접근할 수 있었다.


3. 제출 후 회고🔫

  • 잘한 것
    • 보너스 요구사항까지 다 구현해냈다!
    • 기록
      요구사항 별로 구상 - 초기 구현 - 이슈 - 해결 순으로 기록을 해두었다. 기록이 쌓이면서 어떻게 변화해왔는지 알 수 있어서 좋다.
  • 아쉬운 것
    • fetch작업을 무턱대고 많이하고 있는 것같다. 최소화하는 과정이 필수로 필요하다.
    • 아직 소소한 버그가 많음
    • 기록에 있어서 잘했지만 아쉬운 것
      초기 구현 단계에선 기록을 잘 했는데, 이후에 생각지 못한 이슈를 만나고 해결에 주력을 다하다 보니 세세하게 적는게 어려워졌따. 또, 피곤해서 적지 못하고 자버림 등의 핑계로 매번 완벽히 해내진 못했다.


피드백 & 리팩토링

1. 셀프 리팩토링

  • 토글 여닫기 상태기억
    => 로컬스토리지를 이용하여 토글 여부 저장. 이 값을 이용하여 렌더링

해야할 것

  • 전역 상태관리
    나는 모든 상태관리를 최상단의 App에서 하지 않았다. 컴포넌트 구조를 짰을 때 크게 SideBar와 EditPage로 나눌 수 있었고 각각에서 본인의 하위들에 대한 상태를 관리한다. 그래서 다른 분들과 다르게 App의 코드가 짧았다 ..
    컴포넌트 선언부와 초기화,실행부를 분리하기위해 App에서 모든 상태를 관리하는 것을 권장하니까, 이는 시도해봐야겠다!
  • 트라이 최적화


2. 코드 리뷰

  • 반복 줄이기
    변수, 함수 등을 이용해서 중복 코드를 줄이자
  • 디테일 신경쓰기
    변경되지 않는 값에 대해 let으로 선언하지 않기
    사용하지 않는 코드 신경써서 지우기
  • 트리 탐색 버그 수정
    새 페이지 생성시 ' 제목 없음' 이렇게 빈칸을 두는 바람에 트라이 노드에 이상함이 있었다. 제목은 빈칸이 아닌 값으로 제한하는 조건을 추가하는 중에 발견하였다..
    => 정규화를 사용하였다.
    -->시작(^)이 빈칸(\s)이고 1개이상({1,})이면 전부 ""로 바꾼다.
const nextState = {
      ...this.state,
      title: e.target.value.replace(/^\s{1,}/g, ""),
    };

3. 질문 모음

innerHTML 내에서 onclick 심는 방법

this.render = () => {
    $header.innerHTML = `
    <div class="header_profile">
      <div class="header_profile_name">
        자체제작 노션
      </div>
    </div>
    `;
    $header.addEventListener("click", () => pushRoute("/"));
  };
  this.render();

이렇게 클릭이벤트를 따로 걸어주는데 이를 따로 이벤트리스너로 걸어주는게 아닌 inner에 넣고 싶었다. 이 예시에선 간단하지만, 다른 파일 중에선 이벤트를 걸어야할 요소가 많기도 하고 각각에 따라 다른 처리도 필요해서 번잡해지는 것 같았다.

내가 해본 시도는

<div class="header_profile" onclick=`() => pushRoute("/")`>

<div class="header_profile" onclick=`${() => pushRoute("/")}`>

this.func = () => {
    pushRoute("/");
  };
/...
<div class="header_profile" onclick="this.func()">
// this.func is not a function

모두 실패 ..

<<답변 기다리는중


함수/파일 분리에 대한 고민

어느정도 반복이 되는 코드면 함수나 변수로 묶어 중복을 제거하는게 좋다. 여기에 나아가서 하나 더 고민인 것은, 그렇게 분리한 함수를 다른 파일로 둬야하는지에 대한 것이었다.

명확한 기준을 두진 않았지만, 여러 파일에서 접근해서 사용하는 함수라면 파일을 분리하고 export/import를 하였고, 한 파일(컴포넌트)내에서 여러번 사용되는 정도라면 내부에서 지역함수로 선언했다.

이부분에서 멘토님께 질문드렸는데, 결론은 지금 단계에선 파일을 더 분리할 필요 없다!

강의에서도 그랬듯이 api호출부나 로컬스토리지 접근부, 커스텀이벤트 등 목적이 명확히 나뉘어지는 것에 대해서만 분리하는 것이 좋겠다.

종종 깃허브를 서핑하면서 다른 사람의 프로젝트를 구경했을 때 너무 자잘한 함수 하나까지 파일을 분리해 둔 경우를 본 것 같다. 그 때 궁금한 함수의 본체를 찾으려면 여러 파일을 건너다녀야 하는게 편하진 않았다! 분리하여 얻는 가독성과 분리하지 않아서 얻는 가독성 사이의 적당함을 찾아서,,,


4. 코드리뷰 회고

우선, 보너스 요구사항, 트라이 탐색, 커스텀이벤트 등 구현해내려고 노력을 더한 부분에 대해 칭찬을 받아서 기분이 아~주 좋았다!
내가 알고 있는 미세한 버그들 뿐만 아니라 발견 못했던 부분들을 정말 잘 찾아내주신다. 실제 사용자 피드백의 중요성을 느꼈달까.. 코드면에서도 디테일하게 신경을 써야겠다는 걸 많이 느꼈다.

지난 과제들과 다르게 비슷한 부분이 거의 없는 프로젝트 코드리뷰라서, 나 또한 남의 코드를 보는게 어려우면서도 재밌었다. 구현한 기능은 비슷하기에 다른사람들은 어떻게 구현해냈을지 찾아보는 재미가 컸고 다양한 스타일과 사용하는 문법들을 보면서 적어두고 종종은 적용해보기도 했다.





마무리

나름 길었던 기간을 지나 완성을 하니 너무너무 뿌듯하다! 배포까지 해서 정말 프로젝트 마무리 한 것 같은 기분 ㅎㅎ 부족함을 많이 느끼고 또 많이 배운 기간이었다.

이 개발기를 정리하려고 보니까, 커밋메세지의 중요성을 느꼈다. 최종 결과만 보는게 아니라 그동안의 개발 과정과 코드를 보고 싶은데 커밋메세지가 명확하지 않으면 바로 찾는 것이 어려웠다.

무엇보다 실제 노션의 코드 전부가 보고 싶었다. 바닐라JS는 아닐테니 조금 더 빠르고 자연스러울 수는 있지만, UI도 예쁘고 UX도 편해서 탐이났다(?)..

제출과 코드리뷰는 끝났지만 빌드업 시켜야할 부분은 많고 많다!! 프로젝트 제출 이후 이어진 강의에서 최적화하는 방법과 조금 더 업그레이드 시킬 수 있는 방법들을 배웠는 데 그또한 적용해야겠다. 위에 적은 해야할 것과 최적화 등을 마치고 TS 마이그레이션까지 목표로 잡아보았다 !! 아자자

Tmi : 하루를 투자한 글이 임시저장 이슈로 날라가서 다시 썼다.. 눈물...

profile
코딩하는 고구마 🍠 Life begins at the end of your comfort zone

0개의 댓글