리팩터링을 통한 컴포넌트 설계 최적화

문다현·2024년 11월 21일
1
post-thumbnail

프로젝트 초기 라우팅 구조의 변경

처음에 프로젝트를 시작했을 때, 각 기능을 위해 별도의 폴더를 만들었습니다. 글 작성 페이지는 write, 임시 저장 글은 temp/[postId], 그리고 수정 페이지는 edit/[postId]로 각각 폴더를 분리했습니다.(Next14 App router 방식)

write
temp/[postId]
edit/[postId]

이 구조에서는 글 작성, 임시 저장, 수정이 각각 별도의 폴더에 분리되어 있었고, 각 폴더에 중복된 코드들이 존재했습니다. 결과적으로 관리가 어려워지고, 코드도 반복되어 유지보수가 번거로워졌습니다.

리팩터링 이유: 코드 간소화와 재사용성 증가

기능은 비슷하지만 라우팅이 다르기 때문에 폴더를 나누는 방식은 번거로움을 초래했습니다. 글 작성, 임시 저장, 수정은 결국 사용자가 입력한 데이터를 서버에 제출하는 점에서 기능적으로 동일한 작업을 수행하므로, 이를 하나의 구조로 통합하고 코드 중복을 제거하고자 했습니다.

리팩터링 후 구조

변경된 구조는 searchParams를 이용하여 type(Write, Temp, Edit)id(postId)를 받아 처리하는 방식입니다. 이렇게 하면 폴더를 따로 만들 필요 없이 한 곳에서 모든 로직을 처리할 수 있습니다.

const page = async ({
  searchParams,
}: {
  searchParams: { type: "Write" | "Temp" | "Edit"; id: number };
}) => {
  const { type, id } = searchParams;
  // 검색된 type에 맞는 API 호출 및 처리 로직
};

이 방식은 폴더가 너무 많아지는 문제를 해결하고, 각 기능에 맞는 페이지가 반복되지 않도록 합니다.

prevInfo 처리 및 상태 초기값 변경

글 작성 시 임시 저장 글이나 수정된 글을 불러오는 API 요청이 필요했습니다. 임시 저장된 글을 이어서 작성하거나, 기존 글을 수정하려면 해당 글의 데이터를 미리 불러와야 했습니다. 이를 처리하기 위해 useState useEffect를 사용하여 상태를 관리했는데, 초기 상태값을 이전에 저장된 정보로 설정하는 방식으로 바꿨습니다.

const [prevImgUrl, setPrevImgUrl] = useState(prevInfo?.post.thumbnail_URL || "");

렌더링 순서는 보장이 될까?

Next.js의 서버 컴포넌트는 클라이언트가 요청을 보내기 전에 이미 데이터를 받아옵니다. 이 과정에서 서버 컴포넌트는 데이터를 미리 가져오고 이를 클라이언트 컴포넌트에 props로 전달합니다. 클라이언트 컴포넌트는 이 데이터를 바탕으로 useState로 초기값을 설정하므로, 렌더링 순서가 보장됩니다. 즉, 데이터가 준비된 상태에서 클라이언트 컴포넌트가 렌더링되기 때문에 렌더링 순서에 의한 문제는 발생하지 않습니다.

PostingButtons 분리 및 상태 관리 로직 개선

기존 컴포넌트 구조 및 문제점

Posting.tsx의 기존 구조는 다음과 같았습니다.

// Posting.tsx
<PostingThumbnail /> // 썸네일 담당
<AttachedFile />      // 첨부파일 담당
<CategoryWrite />     // 대분류/소분류 카테고리 담당
<TitleHashTag />      // 제목 및 해시태그 담당
<Tiptap />            // 에디터 관련 기능 담당

그중 Tiptap 내부는 다음과 같이 구성되어 있었습니다:

// Tiptap.tsx
<MenuBar />           // 메뉴바 UI 관리
<EditorContent />     // 에디터 본문
<EditorTab />         // 탭 UI
<PostingButtons />    // 제출 및 임시저장 버튼

기존 구조에서는 PostingButtons가 Tiptap 내부에 위치했기 때문에 아래와 같은 문제들이 있었습니다

1. 데이터 흐름의 복잡성
PostingButtons가 Tiptap 내부에 위치하다 보니, 버튼 클릭 시 데이터 흐름을 파악하기 어려웠습니다.
2. Props Drilling
PostingButtons에서 필요한 데이터와 상태를 전달하기 위해 여러 상태와 함수를 Tiptap까지 내려줘야 했습니다.

Tiptap 내부에서 에디터 인스턴스를 생성하고 이를 기반으로 동작하도록 구성하다 보니, 버튼을 분리하기가 쉽지 않았습니다.

버튼 분리와 상태 동기화

하지만 아무생각없이 Tiptap 내부에 버튼 컴포넌트들을 위치시킨건 아니었습니다.
리렌더링 문제를 고려해야 했습니다. Tiptap의 공식 문서에서 에디터 인스턴스를 쓰는 부분들은 별도의 컴포넌트로 분리하라고 권고하기 때문에 구조적 이유로 useEditor 호출 위치를 상위로 끌어올릴 수 없었습니다
https://tiptap.dev/docs/guides/performance

PostingButtons 컴포넌트가 editor 인스턴스를 안쓴다면 분리할 수 있을텐데..

PostingButtons 컴포넌트 내에서 editor인스턴스를 쓰는 이유가 무엇인가 하고 봤더니 아래 코드같은 부분에서 쓰고 있었습니다.

 const submitByType = (type: "임시저장" | "제출") => {
   const content = editor?.getHTML(); //여기!
   setTab(() => {
	  const updatedTabs = [...tab];
	  updatedTabs[isActiveTab - 1].content = content;
     if (type === "임시저장") {
//
     }
     if (type === "제출") {
//
     }
     return newTabs;
   });
 };

editor 인스턴스가 필요한 이유는 최신 내용을 가져와야 하기 때문이었습니다

submitByType은 제출 버튼을 누르면 호출되는 함수인데 ,
1) 이전 내용까지 마저 최신화, 즉 저장을 하고
2) 그 이후에 제출 요청을 보내야합니다.

editor라는 객체 안에서는 실시간으로 내용이 업데이트 되고,
여러 탭별로 탭 순서,이름,내용을 관리하기 위해 상태를 useState 객체로 선언해서 관리하고 있습니다. 주기적으로 editor 안의 내용과 내 탭 상태를 동기화를 시켜줘야해서 분기를 했었습니다.

트리거 이벤트

1. 제출 버튼을 눌렀을때 (임시저장,최종제출)
현재 탭 내용을 최신화한 뒤 데이터를 제출합니다.
2. 다른 탭으로 전환했을때

  • 다른 탭에서 내용을 적고싶어서 전환할 경우
    현재 탭의 내용을 저장하고, 다른 탭 내용을 불러와야합니다

  • 탭을 추가한 경우
    현재 탭의 내용을 저장하고, 새로운 탭 내용(빈 스트링)을 불러와야합니다

  • 탭 순서를 drag&drop으로 바꿨을 때
    현재 탭의 내용을 저장하고, 마지막으로 마우스를 drop한 탭의 내용을 불러와야합니다.

문제점

초기에는 이처럼 이벤트에 따라 동기화를 하려고 했었습니다. 그러다보니, 로직이 중복되고, 분산되면서 복잡성이 커졌습니다. editor객체도 그에 따라 여러 컴포넌트에서 필요해졌습니다.

실시간 업데이트 vs 이벤트 트리거 업데이트

이벤트로 트리거하는 방식은 여러 이벤트에서 상태를 저장하고 동기화하는 코드가 분산될 수 있습니다. 탭 전환 시, 순서 변경 시, 새 탭 추가 시 모두 개별적으로 상태를 관리해야 할 수 있는데, 이런 부분에서 중복 코드가 발생하고 관리가 복잡해질 수 있습니다. 반면, 실시간 저장 방식은 매순간 동기화를 처리할 수 있기 때문에 코드의 중복을 줄이고 관리 및 유지보수가 쉬운 구조가 됩니다

onUpdate 이벤트를 활용해 에디터 내용이 변경될 때마다 탭 상태를 실시간 동기화하도록 수정했습니다. 그전보다 성능적 효율은 낮아졌을지 몰라도, 안정성과 유지보수 용이성을 얻었다. 모든건 trade-off니..

useReducer 도입으로 상태 관리 단순화

하지만 아직도 상태 관리가 난잡하다고 느꼈습니다. 기존의 상태 관리 방식에서는 여러 setter 함수와 상태가 곳곳에 흩어져 있어서 코드를 이해하고 유지보수하기 어려운 구조였습니다. 특히, 탭 상태를 관리하면서 현재 탭의 내용을 저장하거나 새로운 탭을 추가하는 로직이 복잡하게 섞여 있었고, 상태 변경이 여러 곳에서 이루어져 코드의 가독성과 안정성이 떨어질 가능성이 있었습니다.

🔴 이전 코드

const addTab = () => {
  const newTabLength = tab.length + 1;
  setTab((prev) => {
    const newOneTab = {
      tab_title: `탭이름 ${newTabLength}`,
      content: "",
      tabOrder: newTabLength,
    };
    const newTabs = [...prev, newOneTab];
    const content = editor?.getHTML();
    newTabs[isActiveTab - 1].content = content; // 현재 탭 내용 저장
    setIsActiveTab(isActiveTab + 1); // 새 탭 활성화
    return newTabs;
  });
};

🟢 useReducer 적용 후 코드

const addTab = () => {
  dispatch({ type: "ADD_TAB" });
  setIsActiveTab((prev) => prev + 1);
};

useReducer로 관리한 액션 목록

  • 탭 추가 (ADD_TAB)
  • 탭 삭제 (DELETE_TAB)
  • 탭 이름 변경 (EDIT_TAB_NAME)
  • 탭 내용 저장 (SAVE_TAB_CONTENT)
  • 탭 순서 변경 (REORDER_TABS)

dispatch로 상태를 관리하니 코드 가독성이 좋아지고, 상태 변경 로직이 명확히 분리되었습니다.

PostingButton 분리 성공!

결국 PostingButtons를 Tiptap에서 분리하고 부모컴포넌트인 Posting으로 이동 성공했습니다.

// Posting.tsx
<PostingThumbnail /> // 썸네일 담당
<AttachedFile />      // 첨부파일 담당
<CategoryWrite />     // 대분류/소분류 카테고리 담당
<TitleHashTag />      // 제목 및 해시태그 담당
<Tiptap />            // 에디터 관련 기능 담당
<PostingButtons />  // 제출 및 임시저장 버튼
// Tiptap.tsx
<MenuBar />           // 메뉴바 UI 관리
<EditorContent />     // 에디터 본문
<EditorTab />         // 탭 UI  

1. 구조적 명료함

Posting이 전체적인 상태를 관리하게 되어 컴포넌트 간의 데이터 흐름이 더 명확해졌습니다. 가독성은 덤.

이전에는 버튼이 Tiptap 내부에 위치했기 때문에 에디터 상태를 버튼으로 전달하기 위해 불필요한 props를 넘기는 props drilling 문제가 발생했지만, 이제 Posting이 상태를 중앙에서 관리하면서 필요한 상태와 데이터만 하위 컴포넌트로 전달되므로 코드가 훨씬 간결해졌습니다.

2. 책임 분리

Tiptap 에디터 관련 기능 전담
PostingButtons 제출 및 저장 로직 전담
역할이 명확히 나뉘어 코드 유지보수가 수월해졌습니다. 미래 기능 확장에 대비한 유연한 구조가 되었습니다.

결론

PostingButton이 구조적으로 상위 컴포넌트에 위치했으면 좋겠다는 작은 욕심이 눈덩이 효과처럼 커져 본격적인 리팩터링을 하게되어 결국에는 큰 변화를 갖고왔다.
처음부터 최선을 다했던 코드이기에, '다음에는 리팩터링이 필요없는 완벽한 코드를 짜야지' 라는 의미없는 다짐은 하고 싶지않다. 단지 개발을 할 때의 작은 팁을 얻어간 것 같다.

내가 생각하는 좋은 코드란 무엇인가 라는 질문에 대한 정의는 변함없다.

1순위는 문제없이 돌아가는 코드이고,
2순위가 유지보수성과 확장성과 같은 것들을 고려한 품질이다

처음부터 완벽한 코드를 만들려고 하기보다는, 필요한 부분에서 리팩터링을 통해 점진적으로 개선해 나가면서 점차 더 나은 코드를 만들 수 있다

중요한 건, 문제를 발견했을 때 그것을 개선하려는 마음가짐과 그런 개선이 실제로 가치 있는 변화로 이어지는 것을 보는 것이라 생각한다.

profile
기록 남기기

0개의 댓글