[회고] 노션 프로젝트의 끝, 그리고 시작

Ben·2022년 4월 23일
1

Today I Learned

목록 보기
22/57

개요

이번 일주일동안 노션 프로젝트를 진행하면서 진행 이슈들과 과정, 그리고 느낀점에 대하여 정리한다. 처음에 구조를 어느정도 잡긴 했지만, 뒤에서 거의 완전히 엎을(?) 정도로 리팩터링을 했기때문에 구조에 신경쓰느라 부가 기능은 많이 집어넣지 못했다. 그래도 이번 프로젝트를 진행하면서 배운점이 많았고, 이에 대해 회고하고자 이 글을 쓰게 되었다.

구조 잡기

처음 잡은 구조는 다음과 같다.

  • APP: 모든 state를 저장하고, 필요한 하위 컴포넌트에 상태를 넘겨주는 역할. 따라서 데이터를 수정하는 함수들은 모두 App 컴포넌트에 저장한다.
    • Sidebar: 사이드바는 노션 왼쪽의 도큐먼트 트리를 렌더링하는 컴포넌트이다. Sidebar 컴포넌트는 App 컴포넌트에서 상태를 전달받고 이를 렌더링한다. 다만, 토글, 추가, 삭제기능은 데이터를 변경하는 방식이기에 이벤트 리스너를 컴포넌트에 등록 후, App 컴포넌트에서 콜백 함수를 넘겨주어 App에서 처리한다.
    • MainPage(Editor): 에디터는 노션은 많은 기능을 가지고 있고 마크다운으로 변환도 가능한 rich 에디터이지만, 현실적으로 일주일동안 rich하게 만드는 것은 불가능하므로, inputtextarea 로 구현하며 시간이 남을 경우 <div contentEditable> 로 구현을 해 본다. (그러나 이것이 이루어지는 일은 없었다.)

하면서 어려웠던 점들과 해결 방법들

핵심 구현 기능

  • 사이드바 토글 기능 구현하기
  • 문서 수정, 삭제시 트리에 실시간 반영하기
  • 글의 제목을 수정할 때, 해당 글 제목이 실시간으로 바뀌게 하기
  • 토글 정보 로컬에 저장하기

사이드바 구현시, 도큐먼트 트리 토글 문제

API에서 토글 여부가 존재하지 않았으므로, 이를 따로 구현해야 했다. (물론 요구사항에는 없었다)

토글 기능 구현하기 → 데이터를 받아올 때 토글 정보 넣어주기

서버에서 전해오는 데이터에는 토글을 했는지 안했는지에 대한 정보를 담고 있지 않기 때문에 직접 넣어주어야 했다. bfs나 dfs로 넣어줄 수 있었는데, 나는 dfs 방식을 선택하여 토글 정보를 넣어주었다. 그리고 주어진 트리 구조의 도큐먼트를 트리 형식으로 렌더링 해야 하므로, 렌더링 코드도 재귀적으로 작성해 주었다.

문서 수정, 삭제시 트리에 실시간으로 반영하기

노션 프로젝트는 따로 저장 버튼이 없고 서버에 실시간으로 저장되기 때문에 사이드바 도큐먼트 트리 역시 실시간으로 반영이 되어야 한다고 생각했다. 나에겐 두 가지 선택지가 있었다.

  1. 쉬운 길로 간다 (문서의 변경이 일어날 때마다 도큐먼트 트리를 항상 새로 불러온다.)
  2. 어려운 길로 간다(도큐먼트 트리는 처음 한번만 불러온 뒤, 클라이언트에서 도큐먼트 트리를 조작한다. 다만 새로고침시에도 서버와 동일한 모양이어야 한다)

나는 2번의 방법을 선택하였다. 문서의 변경이 일어날 때마다 서버에서 트리 정보를 요청하는 것은 간단하지만, 서버의 상태가 불안정할 경우 문서 수정이나 삭제 버튼을 누를 시 바로 반영이 안되는 문제가 있을 수 있다고 생각했다. 서버에서 불러오는 것과 동일한 구조를 만들기 위해서 BFS 방식으로 노드를 찾고, 서브 트리를 관리하는 방식으로 이를 구현하였다.

→ 새로 고침 한 뒤에도 동일한 결과를 얻을 수 있었다! 구현 과정은 좀 힘들었지만,,,

이 과정에서도 약간의 이슈가 있었는데, 반환받은 객체가 트리 속의 객체와 다른 참조 주소를 가지고 있는줄 알고 콜백 함수로 노드를 처리하고자 했으나, 한번 시험 삼아서 반환값으로 처리를 해봤더니 동일한 결과를 얻을 수 있었다. 조금 더 클린한 코드를 작성할 수 있게 되었다. 🙂

예시) 이전 bfs함수와 현재 bfs함수

// before
bfs(initialData, id, cb) {
    const q = new Queue();
    initialData.forEach((document) => q.enqueue(document));
    while (!q.isEmpty()) {
      const document = q.dequeue();
      if (document.id === id) {
        if (cb) cb();
        return;
      }
      document.documents.forEach((child) => q.enqueue(child));
    }
    return null;
  }

// after -> 훨씬 깔끔해졌다.
bfs(initialData, id) {
    const q = new Queue();
    initialData.forEach((document) => q.enqueue(document));
    while (!q.isEmpty()) {
      const document = q.dequeue();
      if (document.id === id) return document;
      document.documents.forEach((child) => q.enqueue(child));
    }
    return null;
  }

글의 제목을 수정할 때, 해당 글 제목이 실시간으로 바뀌게 하기

이것도 바로 상위 이슈와 연결이 되어있는데, 만약 제목이 변경될 때마다 서버에 요청해서 제목을 변경한다면 엄청난 낭비일 것이라고 생각했다. 그럼 제목을 100번 바꾸면 제목 하나 바꾸는데 100번의 요청이 발생한단 뜻이니까. 따라서 실시간으로 구현하기 위하여 트리 구조를 직접 변경하는 방식을 취할 수밖에 없었다.

토글 정보 로컬에 저장하기

만들고자 하는 핵심 기능은 어느정도 구현하였지만, 새로고침 할 경우 토글 정보가 저장되지 않아 다 날라가는 어려움이 있었다. 따라서 로컬 스토리지에 토글 정보를 저장할 필요성이 있었다.

처음에 구상했던 방안

  • 현재 도큐먼트 트리를 저장하고 함께 tempSaveDate 를 저장한다(업데이트 날짜). 만약 서버에서 업데이트된 시간과 비교했을 때 더 최신이라면 로컬스토리지 값을 불러오고 아니면 서버에서 불러온 값을 사용한다.
    • 이 방법은 일단 서버에서 updated_at 시간을 전해주지 않는데다가, 트리의 각 노드별로 따로 updated_at 항목이 존재하므로 제목이 바뀌기라도 하면 무조건 상태가 달라지므로 이런 방식으로 처리하는 것이 의미가 없었다.

내가 해결한 방법은 토글 정보 역시 App 상태에 두고, 토글이 발생할 때마다 App 상태를 변경 및 로컬스토리지에 저장하는 방식을 사용하였다. 혹시 모를 경우를 대비하여 항상 Set() 으로 관리하여 토글 상태가 중복되지 않도록 처리해주었다.

마찬가지로 처음 데이터를 불러올 때 동시에 로컬스토리지에서 토글 정보를 불러온 뒤, 그 토글 정보를 바탕으로 { toggled: false }가 아닌 유동적으로 옵션을 부여할 수 있도록 하였다.

// 대충 이런 느낌
const ret = {
  ...document,
  toggled: toggleSet.has(document.id) ? true : false,
};

결과

후술할 Editor가 업데이트 되더라도 트리는 깜빡임 없이 자연스러운 렌더링을 구현할 수 있게 되었다! 😃

컴포넌트 구조가 점점 깊어짐에 따른 상태 관리의 어려움

[ 핵심 이슈 ]

  • 상태가 필요 없는 컴포넌트들도 하위 컴포넌트에 상태를 전달하기 위하여 상태를 받아야 한다.
  • 모든 상태는 App 컴포넌트에서 관리하기 때문에, App의 상태를 바꾸면, 연결된 모든 컴포넌트의 상태도 처리해야 하고, 렌더링 부분에서도 신경써주어야 함
  • 하위 컴포넌트에서 이벤트 발생 시 상위 컴포넌트에서 상태 변경이 이루어져야 하므로 의미 없는 콜백 함수가 점점 많아지는 문제점

현재까지 구조를 그려보면 이런 느낌이다.

만약 여기서 한 뎁스를 더 추가한다고 하자. 그럼 구조는 이렇게 될 것이다.

만약 부모 컴포넌트는 상태가 필요 없지만, 자식 컴포넌트가 상태에 접근하고 값의 변화를 일으켜야한다면, 부모 컴포넌트 역시 상태를 전달받아야 한다.

  • 유지 보수의 어려움
    • 만약에 state가 잘못 전달되기라도 한다면, 어디서부터 잘못 전달되었는지 찾아야하고(물론 크롬 개발자 콘솔이 알려주긴 하지만,,, 귀찮으니까 🥲), 이는 뎁스가 깊어질수록 디버깅이 어려워진다.
  • 불필요한 자식 렌더링
    • 이것도 setState 마다 개별적으로 설정해주면 되지만, 개별적으로 설정하는 것은 매우 귀찮은 일이다. 나는 개별적으로 설정하는 것이 아닌 일반적으로(general) 하게 동작하는 것을 좋아해서, 어떠한 경우에도 동작을 보장할 수 있는 방법을 원했다.

[ 전역 상태 관리 공간 도입 ]

따라서 나는 전역 상태 관리 (리덕스 처럼 단방향 데이터 흐름이면 더욱 좋고)의 필요성을 느꼈다. 그러나 혼자 힘으로 아이디어를 구현하는 것은 어려웠기 때문에, 개발자 황준일님의 블로그에 좋은 내용이 있어 이를 참고하였다. (물론 이해하고 쓰느라 엄청 늦어졌다. 클로저의 개념에 대해 알고 있어도 클로저를 만들어 사용해본적은 없었기 때문에)

전역 상태 관리에 대하여 글을 더 쓸 것이니, 여기서는 개념만 짧게 설명하고 넘어가도록 하겠다.

전역 상태 관리(observer pattern)

전역 상태 관리는, 상태를 전역으로 관리하고, 상태가 필요하거나 상태의 값을 조작하는 함수들을 구독하여 상태의 변화가 일어났을 때, 상태를 구독한 모든 함수들을 실행할수 있게 만드는 것이 핵심이다.

따라서 상태는 리덕스처럼 한 방향으로 데이터 방향이 이동할 수 있게끔(항상 getState를 통해 값을 받고, 값을 변경할 경우 dispatch를 통해 해당 키를 가진 상태를 변경) 만들어 주었고, 상태에 접근하는 렌더 함수들만 observe() 로 구독하였다.

콜백 함수 처리

따라서 콜백 함수를 굳이 App에서 처리해야 할 필요가 없어졌고, 커스텀 이벤트를 디스패치하여 동작을 수행하였다. EventListener 컴포넌트를 따로 만들어 거기서 모든 커스텀 이벤트를 처리하도록 했다.

결과

setState 가 혹시나 잘못된 형식의 데이터를 전달하거나 받는지, 또는 불필요한 렌더링이 발생하는지 노심초사 할 필요 없이, 상태 접근이 필요한 함수만 구독하면 되기 때문에 코드도 훨씬 깔끔해지고 유지보수가 용이해졌으며, 실시간으로 서버와 통신하면서 발생하는 버벅임을 최소화 할 수 있게 되었다.

debounce 관련 이슈

한 글자씩 쓸 때마다 업데이트를 요청할 수 없기 때문에 모든 입력이 끝나고 휴식(?) 상태일 때 업데이트를 하고자 debounce를 도입하였는데, 잘 되지 않았다.

window.addEventListener('keydown', (e) => {
  //...
  debounce(fn());
});

이런 형식으로 구현했는데, 생각해보면 안되는 것을 알 수 있었는데, 잘 몰랐다.

이 함수의 문제점

  1. keydown 이벤트가 발생할 때 마다 콜백 함수가 실행된다.
  2. 콜백함수가 실행되고 특정 시간 이후 debounce 함수가 실행된다.

근데 콜백함수는 디바운스가 적용되지 않기 때문에 그냥 키가 입력한 뒤 세팅한 시간 얼마 뒤에 실행되는 것이지 실질적으로 디바운스 효과는 없었다.

따라서 이렇게 써야 한다.

window.addEventListener('keydown', debounce((e) => {
  console.log(e)
}, 500))

debounce 관련 글자가 사라지는 문제 발생

keydown 이벤트를 디바운스를 설정하니 간헐적으로 입력한 글자가 사라지는 문제가 발생했다. 따라서 같은 이벤트를 하나는 디바운스를 적용한 콜백, 하나는 디바운스를 적용하지 않은 콜백 함수로 나누어서 해결하였으나, 아직까지 썩 맘에드는 부분은 아니다.

App 컴포넌트의 슈퍼클래스화

리팩토링을 진행하기 전, App 컴포넌트는 너무나 많은 일을 하고 있었다.

앱이 하고 있는 일들

  • 상태 저장
  • 라우터 처리
  • fetch 함수들
  • tree 구조 변경 함수들
  • 각종 콜백함수들
  • ...

무려 App 컴포넌트가 300줄(!) 가까이 되었었다. 이것은 보는 나도 고통스러울 뿐만 아니라 리뷰를 해주는 팀원과 멘토님한테도 엄청난 고통일 것이다. 그리고 앞으로 새로운 기능을 추가하는데 있어 큰 걸림돌이 될 것은 안 봐도 자명했다.

이렇게 많은 메서드를 담고 있었던 이유는, 메서드들이 상태 변경에 직접적인 영향을 미치기 때문에 this.state 에 접근하기 때문이었다.

따라서 첫 번째로 리팩토링을 진행한 과정은

  1. 각 메서드를 상태를 조작하는 부분과 상태를 건드리지 않는 부분으로 분리
  2. 이 과정에서 비슷한 동작 방식을 갖는 메서드들을 하나로 합침

이 과정을 통해 트리를 다루는 부분은 트리 클래스로 따로 빼내었고 그 이후 상태를 조작하는 메서드 부분만 App 컴포넌트에 남겼다. 그리고 fetch 부분 역시 처음에는 App 에서 상태관리를 하기 때문에 전부 App에 넣었지만 상태를 직접적으로 건들지 않기 때문에 따로 분리했다.

이 과정을 통해 절반 정도로 줄일 수 있었다.

나머지 부분은 전역 상태 관리를 도입함에 따라 필요 없어진 상태관리, 콜백 함수들을 정리하면서 최종 코드는 약 100줄 정도이며, 200줄 다이어트에 성공했다!

회고

느낀점, 이 부분은 잘했다! 😃

[ 성공적인 옵저버 패턴의 도입 결정(전역 상태 관리) ]

옵저버 패턴을 도입하기 전에 이미 시간이 많이 지나 있었고, 또 개념 자체가 어려워서 이해하는데 시간이 오래 걸렸지만, 그래도 시간을 투자하여 공부하고 적용한 보람이 있었다. 전역 상태 관리를 도입하기 전에는 새로운 컴포넌트를 추가하면 항상 잘 동작하는지 불안하고, 컴포넌트간 종속성이 심하여 새로운 기능을 추가하는 것이 매우 어렵고 부담스러운 일이었으나, 이제는 렌더 메서드만 등록해주면 이러한 걱정을 할 필요가 사라졌다.

구조 변경(한번 엎느라) 정작 기능은 별로 없지만, 앞으로 더 빠른 속도로 기능 개발을 할 수 있는 발판을 만들었다는 것에 큰 의미가 있었고 매우 기쁘다.

역시 리덕스와 같은 상태 관리 라이브러리도 바닐라 자바스크립트로 만들어졌다는 사실을 새삼 깨닫고, 나도 저런 구조를 만드는게 꿈 같은 일은 아니구나 라는 자신감도 얻었다. (물론 실력은 멀었다)

새로운 구조는 다음과 같다.

  1. 모든 컴포넌트는 필요에따라 전역 store를 구독한다.
  2. 데이터 처리는 모두 event listener 컴포넌트에서 처리한다.
  3. 컴포넌트는 App, sidebar, editor, modal로 구성되어있다.

[ 컴포넌트가 슈퍼클래스가 되는 것에 경각심을 갖고 리팩토링을 진행한 것 ]

앱 컴포넌트를 줄이기 위해 필사적인(?) 노력을 했다. 이 과정을 통해 함수의 재사용성을 높이고 유연화 할 수 있는 방법에 대해 고민하는 시간을 가질 수 있었다. 그리고 유연해진 덕분에 좀 더 클래스를 카테고리 범주로 묶어 작은 단위로 쪼갤 수 있었으며, App 컴포넌트는 처음 데이터를 받아오고, 라우터 처리만 할 수 있게 되었다. 앞으로도 유지보수와 재사용, 그리고 가독성에 좀 더 신경써서 컴포넌트를 작성할 수 있도록 연습해야겠다.

더 보완해야 할 점 → 태도나 구현 방안 등에서

[ 기능 부분 ]

  • contentEditable
  • 사이드바 버튼 active시 옆에 리스트도 같이 액티브되는 문제
  • 사이드바 숨기고 표시하는 방식 도입하기!
  • 배포 사이트에서 새로고침할 때 간헐적으로 발생하는 404문제 → (하..)

[ 태도 부분 ]

  • 해결하고자 하는 이슈가 있다면 이것을 왜 해결하고 싶었는지(무슨 문제가 있어서인지, 아니면 어떤 부분에서 불편함을 느꼈는지) 같이 기록하는 연습을 꼭 하기
    • 나중에 회고하려니까 정작 그때 내가 왜 이걸 못했지? 지금은 잘하는데 생각이 들어 피드백할 내용이 많았음에도 별로 기억이 안난다. 시간이 없더라도 이런 이슈가 있었고, 이것을 어떻게 해결하면 될지에 대한 메모를 짤막하게 하는 습관을 들이자.

총평

결과적으로 많은 기능을 구현하지는 못했지만, 많은 것들을 느끼고 배울 수 있는 좋은 기회였다. 특히 바닐라 자바스크립트의 중요성은 정말 어마무시하다는 사실을 다시 깨달았다. 리액트, vue 등 어떤 프레임워크를 사용하더라도 다 바닐라 자바스크립트로부터 디자인 패턴을 따라 사용자가 손 쉽게 구현할 수 있도록 만든 것임을 새삼 깨달았고, 그만큼 프레임워크를 이해하기 위해서는 패턴에 대하여 깊은 이해가 필요하다는 사실을 깨달았다.

기록의 중요성!! 아무리 강조해도 지나치지 않는다.

다음 프로젝트에는 좀 더 개선된 모습으로 참여하겠다.

이것은 프로젝트 결과물이다. 어느 정도는 만들었지만 아직 추가해야 할 기능들이 많다. 코드와 배포한 사이트는 API 공개가 금지되어 있으므로 따로 공개하지 않을 예정이다.

프로젝트 제출 기간은 끝났지만, 계속 리팩토링과 기능 추가를 거쳐 남들에게 부끄럽지 않은 사이트를 만드는 것이 목표다. 끝이 아닌 시작이다!

profile
New Blog -> https://portfolio-mrbartrns.vercel.app

2개의 댓글

comment-user-thumbnail
2022년 4월 23일

와우 진짜 수고하셨어요!! 글도 잘쓰시네용! 감탄하고 갑니다 총총🏃🏻‍♀️

1개의 답글