본문 바로가기

FrontEnd

리액트 디자인패턴 : Layout Component (레이아웃 컴포넌트 패턴)

반응형

TLDR : 레이아웃 컴포넌트 패턴은 불필요한 렌더링을 없애 성능을 개선한다. 컴포넌트 트리의 높이를 낮추어 props drilling의 고통을 해결하고, 유지보수성을 향상시킨다.

사람들이 간과하는 리액트의 성능 최적화 팩터

당신의 리액트 앱을 느리게 하는 실수

 

위 두가지 게시물이 지적하는 요소는 컴포넌트의 위치다.

자주 바뀌는 공통 상태가 위에 있을 수록 하위 컴포넌트들은 더 많이, 자주 그려질 가능성이 크다.

 

이를 피하기 위한 방법은 다음과 같다

  • 리렌더링이 불필요한 엘리먼트를 리렌더링 하지 않는다.
  • 리렌더링을 유발하는 관심사에 따라 컴포넌트를 분리한다.

 

당신의 리액트 앱을 느리게 하는 실수에서 글쓴이는 일반인들이 컴포넌트를 구조화하는 방식을 지적한다.

function App() {
  return (
    <div>
      <MainNav />
      <Homepage />
    </div>
  )
}
function MainNav() {
  return (
    <div>
      <GitHubLogo />
      <SiteSearch />
      <NavLinks />
      <NotificationBell />
      <CreateDropdown />
      <ProfileDropdown />
    </div>
  )
}
function Homepage() {
  return (
    <div>
      <LeftNav />
      <CenterContent />
      <RightContent />
    </div>
  )
}
function LeftNav() {
  return (
    <div>
      <DashboardDropdown />
      <Repositories />
      <Teams />
    </div>
  )
}
function CenterContent() {
  return (
    <div>
      <RecentActivity />
      <AllActivity />
    </div>
  )
}
function RightContent() {
  return (
    <div>
      <Notices />
      <ExploreRepos />
    </div>
  )
}

이 방식의 문제점은 컴포넌트 트리의 깊이가 너무 깊다는 것과, 그에 따른 상위 컴포넌트의 하위 컴포넌트 변경 범위가 굉장히 크다는 것이다.

예를 들어 Homepage 컴포넌트에 state가 있다 가정하면, 네비게이션을 제외한 모든 부분이 리렌더링된다.

또한 props drilling도 문제가 된다. App에서 AllActivity까지 중간에 두 개의 컴포넌트를 거쳐 전달해야 한다.

<App /> -> <Homepage /> -> <CenterContent /> -> <AllActivity />

이를 어떻게 개선할까? 

먼저 App 컴포넌트를 보자.

MainNav 컴포넌트와, Homepage 컴포넌트가 layout 역할만 하게 바뀌었다.

function App() {
  return (
    <div>
      <MainNav>
        <GitHubLogo />
        <SiteSearch />
        <NavLinks />
        <NotificationBell />
        <CreateDropdown />
        <ProfileDropdown />
      </MainNav>
      <Homepage
        leftNav={
          <LeftNav>
            <DashboardDropdown />
            <Repositories />
            <Teams />
          </LeftNav>
        }
        centerContent={
          <CenterContent>
            <RecentActivity />
            <AllActivity />
          </CenterContent>
        }
        rightContent={
          <RightContent>
            <Notices />
            <ExploreRepos />
          </RightContent>
        }
      />
    </div>
  )
}

MainNavHomepage 컴포넌트는 구조를 잡는 역할만 하며, 자체적으로 상태를 관리하지 않는다.

Homepage의 경우 레이아웃을 위해 여러개의 엘리먼트를 필요로 한다. (좌측 네비게이션, 중앙 컨텐츠, 우측 컨텐츠)

따라서 이를 slot과 같이 구멍을 뚫어놓고, 컴포넌트를 전달받을 수 있다.

또한 슬롯의 내부에도 layout 역할을 하는 컴포넌트들 (ex LeftNav)을 사용할 수 있다.

// layout components
function MainNav({children}) {
  return <div>{children}</div>
}
function Homepage({leftNav, centerContent, rightContent}) {
  return (
    <div>
      {leftNav}
      {centerContent}
      {rightContent}
    </div>
  )
}
function LeftNav({children}) {
  return <div>{children}</div>
}
function CenterContent({children}) {
  return <div>{children}</div>
}
function RightContent({children}) {
  return <div>{children}</div>
}
// .. other components

해당 패턴의 장점은 다음과 같다.

1. 컴포넌트 트리 깊이가 낮아진다.

props를 전달하는 경로가 짧아졌다.

user data를 App에서 전달한다고 가정하자. ContextAPI가 필요한가?

asis : <App /> -> <Homepage /> -> <CenterContent /> -> <AllActivity />
tobe : <App /> -> <AllActivity />

2. 레이아웃 컴포넌트는 리렌더링 되지 않는다.

레이아웃 컴포넌트는 무상태이고, stateful한 컴포넌트들을 자식으로 상단에서 전달받을 뿐이므로, 앱이 동작하는 동안 리렌더링되지 않는다.

재미있는 것은, 위 예제에서 Homepage에 상태를 추가해도,

props를 통해 전달된 leftNav, centerContent,rightContent은 리렌더링되지 않는다.

왜일까?

 

정말 간단한 리액트 최적화 팁에 따르면,

 Homepage 컴포넌트가 호출될 때, leftNav, centerContent,rightContent가 children으로 전달되는데,

해당 컴포넌트로 props로 전달된 children 객체들의 props 객체 자체가 그대로이기 때문에,

(상위 컴포넌트인 App에서 전달된 props 그대로 사용하기 때문이다.)

리액트는 해당 컴포넌트들을 리렌더링하지 않는다고 한다.

 

과연 그럴까? props가 childern으로 전달되지 않아서 그런게 아닐까 싶어서 아래와 같이 테스트해 보았다.

컴포넌트 내부에서 props를 메모하고,  {Setter(props)}와 같이 호출해 보았더니, 렌더링이 일어나지 않았다.

export default function App() {
  return (
    <div className="App">
      <h1>immutable props test</h1>
      <Context test={<NotRerender />} />
    </div>
  );
}

const NotRerender = () => <div>not Rerender</div>;

function Context({ test }: { test: React.ReactNode }) {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState("1");
  const increaseCnt = useCallback(() => setCount((v) => v + 1), []);
  const increaseText = useCallback(
    () => setText((v) => String(Number(v) + 1)),
    []
  );
  const props = React.useMemo(
    () => ({
      increaseCnt,
      increaseText
    }),
    []
  );
  return (
    <div>
      {test}
      <div>{count}</div>
      <div>{text}</div>
      {Setter(props)}
      <Setter
        increaseCnt={props.increaseCnt}
        increaseText={props.increaseText}
      />
    </div>
  );
}

function Setter(props: { increaseCnt: () => void; increaseText: () => void }) {
  return (
    <div>
      <button onClick={props.increaseCnt}>cnt up</button>
      <button onClick={props.increaseText}>txt up</button>
    </div>
  );
}

Setter 컴포넌트 두게를 렌더링
하나만 렌더링되는 모습을 볼 수 있다.

3. 시각적 테스트에 유용하다.

레이아웃 컴포넌트는 무상태이다.

자식 컴포넌트들을 VAC 패턴으로 구현하여, 외부 데이터 의존성 없이 slot에 대체 삽입하는 방식으로, 스토리북 및 시각적 테스트에서 사용할 수 있다.

 

3번을 잘 응용하면, 리렌더링 버튼과 리스트 컴포넌트를 분리하여, refetch 시, memo없이 버튼이 리렌더링되지 않도록 할 수 있다.

리스트 최적화 팁에서 해당 내용을 다루고 있다.

참고로 해당 패턴은 공식 문서 : composition-vs-inheritance 에서도 권장한다.

반응형