우당탕탕 Next.js 개발기 - ① 추상화 수준을 맞춘 클린한 Modal 개발하기 (1)

김동현·2024년 7월 15일
18
post-thumbnail

이번 방학과 나의 취업 준비를 맞이하여, 진행중인 프로젝트에서 다뤄 볼 법한 주제 들을 꾸준한 블로깅을 해볼 생각입니다.
현재 프로젝트는 Next.js / Typescript / MUI 의 조합으로 이루어져 있으며, 연재 기간은 3-4일, 10개 이상의 주제들로 채워볼 예정입니다. 지난 번 포스팅인 경로 탐색처럼 한번만 연재하고 끝내지 않습니다.
React 나 Next.js 를 아예 모르는 사람들을 대상으로 글을 작성하지는 않습니다. 저와 비슷한 수준의 고민을 하고 계신 저연차 프론트엔드 개발자분들에게 조금이라도 도움이 되길 바랍니다.

React 에서 Modal 을 이용하는 가장 흔한 방식

Modal 이 무엇인지부터 짚고 넘어가겠습니다. Modal은 기존의 브라우저 페이지 위에 새로운 윈도우 창이 아닌, 레이어를 까는 것을 말합니다. 아래와 같은 UI 예시가 Modal 의 예시입니다.

이러한 Modal 의 가장 흔한 구현 방식을 살펴보면 아래와 같은 특징을 지닙니다.

  1. Modal Component 자체를 상위 컴포넌트에서 선언하고 렌더링 합니다.
  2. Modal Component상태관리를 상위 컴포넌트에서 직접 제어 합니다.

무슨 말인지 직접 코드로 살펴보도록 합시다. 서론에 밝혔듯, 이번 프로젝트에서는 MUI 를 사용중입니다. Dialog 는 MUI에서 Modal Component 의 일종입니다.

참고로 MUI 의 DialogModal 을 사용하면 여러모로 좋은 점과 불편한 점이 공존합니다.
우선은 open 이라는 boolean props 를 통해 열고 닫음을 간단하게 제어할 수 있음을 알려둡니다.

// Layout.tsx
export function CustomerClientLayout() {
  const [openModal, setOpenModal] = useState(false);
  const [openDrawer, setOpenDrawer] = useState(false);

  return (
    <>
      {/* 중략 */}
      <ExampleDialog open={openModal} onClose={() => setOpenModal(false)} />
      <MenuDrawer open={openDrawer} onClose={() => setOpenDrawer(false)} />
    </>
  );
}

위의 코드는 아주 정상적인 코드이고 아주 정상적으로 작동하는 코드입니다. 그러면 위에서 서술했던 사항들에 대해서 풀어서 이야기를 해보고자 합니다.

  1. Modal Component 자체를 상위 컴포넌트에서 선언하고 렌더링 합니다.
    • Layout.tsx 에서 ExmpaleDialog 를 선언하고 렌더링 한 모습을 볼 수 있습니다.
    • && 연산자를 써서 openModal 이 true 일때만 렌더링 하는 조건부 렌더링으로 조금 더 최적화를 시킬 수는 있겠지만, 어쨌든 선언과 렌더링은 필수입니다.
  2. Modal Component상태관리를 상위 컴포넌트에서 직접 제어합니다.
    • 상위 컴포넌트에서 (open / close) 이라는 상태관리를 무조건 직접 제어해야 합나다.
    • 핵심은 상태관리가 상위 컴포넌트에서 이루어진다는 점입니다. Modal 자체는 open 과 관련해서는 statless 한 상태가 됩니다.

참고로 Drawer도 Modal 의 일종입니다. 결국 화면 위에 렌더링 된다는 점에서는 동일하기 때문입니다.

가장 흔한 방식의 문제점

가장 흔한 방식은 다음과 같은 문제점을 가지고 있습니다. 이 문제점을 일목 요연하고 에니메이션 효과까지 첨부한 효율적인 modal 관리 with React (1) 를 참고하면 더 좋습니다.
1. 추상화 수준을 맞추기 위해 Modal Component 내부에서 state 관리를 하고 싶은 경우

  • 먼저 이 영상을 보도록 합시다.
    진유림님께서 토스 컨퍼런스에서 발표한 내용으로 추상화에 대한 이해가 훨씬 잘 될 것이다.
  • 스크린샷 상에서 왼쪽 코드가 위에서 예시로 쓴 코드와 기시감이 들정도로 비슷하다는 생각이 들어야 합니다. 예시 코드가 매우 짧아서 감이 안 올수 있습니다. 조금 길게 늘여쓰면서 비교를 해보겠습니다.
  • 감이 오시는가요? 왼쪽 코드에서는 하나의 함수에서 결국 openModal(false) 라는 추상화 수준을 낮추는 작업을 해야만 했습니다. 상위 컴포넌트에서 해야하는 작업은 Dialog 를 눌렀을 때 비동기 작업만 하는 것입니다
  • 하지만 저의 코드에서는 비동기 작업 + 모달 상태 제어 를 해야했기에 추상화 수준이 낮아져버렸습니다
    따라서 추상화 수준을 동등하게 맞추는 작업이 필요해졌습니다.
  1. 여러 개의 모달이 필요한 경우
  • 더 갈 것도 없이 바로 코드를 보여주면 이해가 됩니다.
const App = () => {
  const [isOpen1, setOpen1] = useState(false);
  const [isOpen2, setOpen2] = useState(false);
  const [isOpen3, setOpen3] = useState(false);
  const [isOpen4, setOpen4] = useState(false);


  const handleClick1 = () => {
    setOpen1(true);
  };

  const handleClick2 = () => {
    setOpen2(true);
  };

  const handleClick3 = () => {
    setOpen3(true);
  };

  const handleClick4 = () => {
    setOpen4(true);
  };

  return (
    <div className="App">
      <button onClick={handleClick1}>모달1 열기</button>
      <button onClick={handleClick2}>모달2 열기</button>
      <button onClick={handleClick3}>모달3 열기</button>
      <button onClick={handleClick4}>모달4 열기</button>
      <MyModal1 isOpen={isOpen1} />
      <MyModal2 isOpen={isOpen2} />
      <MyModal3 isOpen={isOpen3} />
      <MyModal4 isOpen={isOpen4} />
   )
}
  • 이런 식의 코드 작성은 너무 많은 양의 state 와 너무 많은 양의 렌더링이 발생하게 됩니다.
  1. 하위 컴포넌트에서 모달을 열어야 하는 경우
const App = () => {
  const [isOpen, setOpen] = useState(false);

  const openModal = () => {
    setOpen(true);
  };

  return (
    <div className="App">
      <button onClick={openModal}>모달 열기</button>
      <ChildComponent openModal={openModal} />
      <ChildComponent2 openModal={openModal} />
      <ChildComponent3 openModal={openModal} />
      <ChildComponent4 openModal={openModal} />
      <MyModal isOpen={isOpen} />
    </div>
  );
};
  • 이와 같이 열어주는 함수를 props 로 넘겨주면 그만이라고 생각할 수 있습니다..
  • 하지만 얼마나 깊게 내려갈지 모르는 props drilling 문제가 발생할 수 있고, 불필요한 리렌더링이 과하게 발생할 수 있다는 문제점도 가지고 있습니다.
  1. 리스트에서 모달을 선언해야 하는 경우

    사실 이 경우의 수 때문에 이 글을 작성했다 해도 과언이 아닙니다.
    특히 React-Native 개발자 분들은 더더욱 공감을 할 거라고 생각합니다.

  • 다음과 같은 요구사항이 있다고 생각해봅시다.
  • List Component 에서 각각의 컴포넌트를 클릭했을때 그 컴포넌트 내용에 종속적인 Modal 을 띄워야 하는 것입니다.
  • 예를 들어 다음과 같은 순간이다.
  • 코드로 구현할려면 어떻게 해야할까? 먼저 매 리스트 아이템에 모달을 선언하는 방식이 생각납니다.
export function ExampleComponent() {
  const [selectedRowId, setSelectedRowId] = useState<string | undefined>(
    undefined,
  );
  
  const [openExchangeDialog, setOpenExchangeDialog] = useState(false);


  return (
    <>
      <Stack spacing={1} direction="column" width="100%">
              {visibleRows.map(row => (
              <>
	              <TokenExchangeDialog
		              open={openExchangeDialog}
		              onClose={() => setOpenExchangeDialog(false)}
		              name={row.title}
		            />
			           <TokenRow
                  key={row.id}
                  {...row}
                  onRowClick={() => {
                    setSelectedRowId(row.id);
                  }}
                />
              </>
              ))}
      </Stack>
    </>
  );
}

아무런 문제가 없어보이나 실상은 큰 문제가 있습니다.
1. 일단 모든 리스트에 중복되서 모달이 생겨난다. 즉 렌더링 과정에서 매우 큰 손실을 입게 됩니다.
2. 정상적으로 동작하지도 않는다. 왜냐하면 모든 모달이 동시에 open 하기 때문입니다.
이런 문제점을 해결하기 위해서 아래와 같이 필자도 울며 겨자먹기로 코드를 작성해 왔습니다.

export function ExampleComponent() {
  const [selectedRowTitle, setSelectedRowTitle] = useState<string | undefined>(
    undefined,
  );
  
  const [openExchangeDialog, setOpenExchangeDialog] = useState(false);

  useEffect(() => {
    if (selectedRowTitle === undefined) return;

    setOpenExchangeDialog(true);
  }, [selectedRowId]);

  return (
    <>
      <Stack spacing={1} direction="column" width="100%">
              {visibleRows.map(row => (
			           <TokenRow
                  key={row.id}
                  {...row}
                  onRowClick={() => {
                    setSelectedRowTitle(row.title);
                  }}
                />
              </>
              ))}
      </Stack>
      <TokenExchangeDialog
	      open={openExchangeDialog}
	      onClose={() => setOpenExchangeDialog(false)}
	      name={selectedRowTitle}
	   />
    </>
  );
}
  • 이 코드는 아래와 같이 동작합니다.. (state diagram 을 참고하면서 보시면 좋습니다)
    a. ListItem 에서 하나를 클릭합니다.
    b. 클릭한 List 의 상태를 state 로 저장합니다.
    c. state 가 변화됐다면 해당 state 를 이용하여 특정 modal 을 열어줍니다.
  • 아니 추상화 수준을 낮춰도 모자란 판에, useEffect 를 이용하여 더욱 코드를 디버깅 하기 어렵게 해버렸습니다. 다시 말해 가독성이 매우 떨어지는 코드입니다. 여러분들은 저 코드를 읽고나서 한번에 state 의 흐름이 읽혀지십니까?

왜 이런 문제들이 생겨났는가?

모든 문제의 근간은 부모 컴포넌트가 Modal열고 닫음 state 를 직접 관리해서 문제가 생깁니다. 따라서 직접 state 를 관리하지 않게 된다면 다음과 같이 코드를 작성할 수 있습니다. (마지막 부분의 코드만 가져와 보았습니다.)

export function ExampleComponent() {
  const {openModal} = useModal('TokenExchangeDialog'); 
 
  return (
    <>
      <Stack spacing={1} direction="column" width="100%">
              {visibleRows.map(row => (
			           <TokenRow
                  key={row.id}
                  {...row}
                  onRowClick={() => {
				    openModal({
                      title: row.title,
                    });
                  }}
                />
              </>
              ))}
      </Stack>
    </>
  );
}

이제 모든 문제가 풀렸습니다.
1. 추상화 수준이 말끔해졌습니다. 상위 컴포넌트에서는 더이상 열었는가? 닫았는가? 자체를 관리할 필요 없이

  • modal 을 열어라 : openModal()
  • modal 을 닫아라 : closeModal()
    만을 이용하면 됩니다.
  1. 중복 및 하위 컴포넌트는 더이상 신경쓰지 않아도 됩니다.. Modal을 직접적으로 컴포넌트에서 선언하지 않습니다. 그저 우리는 훅만 호출할 뿐입니다..
  2. 리스트에서 커스터마이징(특정 props 를 전달해야 하는) 모달을 호출해야 하더라도 문제가 생기지 않습니다. 위에서 본 예시처럼 그저 리스트 아이템 내에서 openModal() 을 선언해버리면 됩니다.

나의 해결법 (feat. forwardRef)

  • 다음 포스팅에서 자세하게 작성해볼 예정입니다. 대략 개념만을 잡은채, GPT 의 도움을 많이 받았음을 미리 서술해둡니다.

Reference

profile
Frontend Developer

5개의 댓글

와 이거 되게 힘들어서 헤맸는데, 좋은 글이네요. 다음 글도 기대해볼게요

1개의 답글
comment-user-thumbnail
2024년 7월 22일

fowardRef 를 통해 상위 페이지에 콜백으로 전달 할 수 있군요!

1개의 답글
comment-user-thumbnail
2024년 7월 24일

고민이 많았지만 전반적으로 좋은 글입니다. 앞으로 나올 작품을 읽는 게 너무 기대돼요. retro bowl

답글 달기