유지보수하기 좋은 React 코드를 작성하는 방법에 대한 고찰

React는 사용하기 편한 라이브러리라고 생각한다.

단순한 웹 페이지를 만드는데 있어서 리액트는 배우기 쉽고, 또한 사용하기도 간단한편이라고 느낀다.

하지만, 실제 서비스되는 클라이언트 웹은 수십, 수백개의 상태와 수백, 수천개의 컴포넌트로 이루어지는 거대한 프로젝트일 것이다.

모든 개발자가 새로운 서비스를 구현하지는 않을 것이고, 내가 작성한 코드는 언젠가 다른 이에 의해서 유지 보수되어야 할 것이다.

때문에, 읽기 쉽고 수정하기 쉬운 코드를 작성하는 방법에 대해서 생각해보았고, 이를 기록으로 남기고자 하였다.

 

🍎 지극히 주관적인 생각으로 쓰인 글이므로, 틀린 내용이 많을 수도 있습니다. 따라서 댓글로 의견을 남겨주시면 감사하겠습니다.

추상화 (abstract)

올바른 표현인지는 모르지만, 추상화라고 적기로 하였다.

어느정도 규모가 있는 프로젝트에서는 필연적으로 전역 상태 관리 라이브러리가 사용된다.

가장 많이 사용되는 라이브러리로 Redux를 이용하여 컴포넌트를 작성했다고 가정해보자.

import React from 'react';
import {useSelector, useDispatch} from 'react-redux';

export default App() {
  const dispatch = useDispatch();
  const shopList = useSelector((state) => state.shop.list);
  return (
      <div>
          <h1>SHOP LIST</h1>
            {shopList.map((el, idx) => (
            	<div key={idx} onClick={() => dispatch({
                  type: "REMOVE_SHOP_ITEM",
                  payload: idx,
                })}>
                	<h2>{el.name}</h2>
                	<h3>{el.price}</h3>
                </div>
            ))}
      </div>
    )
}

판매할 상품의 목록을 보여주고, 클릭하면 해당 상품을 삭제도 할 수 있는 컴포넌트가 만들어졌다.

물론, 이대로도 잘 동작하고 문제는 없다.

다만, 이 프로젝트는 영원히 리덕스만 사용되는 걸까?

 

미래에 어떠한 이유에서 리덕스를 사용하지 않고 MobX, Recoil, Zustand, 또는 React Query와 같은 라이브러리를 사용하기로 회의 결과가 정해질 수 있다.

이 때 기존의 Redux 코드를 지워낼 사람이 당신이 될 수도 있다.

물론, 먼 미래 당신이 아닌 다른 실무자가 이 코드를 수정할 수도 있지만 기본적으로 이 코드를 수정하고자 한다면 해당 Redux 로직이 사용된 모든 컴포넌트를 찾아가 Redux를 새로운 라이브러리의 로직으로 교체해야 할 것이다.

 

이에 난 먼 미래의 수고를 덜 수 있는 방법에 대해서 생각해보았는데, 흔히 말하는 추상화 라는 개념에 대해서 생각해보았다.

내부에 구현이 어떻게 되어있든 useGetShopList라는 메서드를 호출하면, 우리는 상품의 목록을 얻을 수 있는 것이다.

useRemoveShopItemByIndex(index)를 호출하면, 해당 인덱스의 아이템을 리스트로부터 제거하는 것이다.

 

위의 코드를 다음과 같이 수정해보자.

import React from 'react';
import {useGetShopList, useRemoveShopItemByIndex} from "./../shopList";

export default App() {
  const shopList = useGetShopList();
  return (
      <div>
          <h1>SHOP LIST</h1>
            {shopList.map((el, idx) => (
            	<div key={idx} onClick={() => useRemoveShopItemByIndex(idx)}>
                	<h2>{el.name}</h2>
                	<h3>{el.price}</h3>
                </div>
            ))}
      </div>
    )
}

기존에 리덕스를 사용했던 로직이 추상화되었다.

이 컴포넌트만 보면, 해당 함수가 어떻게 구현되어있는지 알 수가 없다.

다만, 확실한 것은 함수의 이름으로 보아 상품 리스트를 가져오고 아이템을 삭제할 수 있음을 안다는 것이다.

 

그리고 만약 우리가 더 이상 Redux를 사용하지 않고 다른 라이브러리를 사용하여 전역 상태를 구현하고자 한다고 가정하면, 다음의 코드를 수정할 수 있다.

import {useSelector, useDispatch} from 'react-redux';

const useGetShopList = () => {
  const list = useSelector((state) => state.shop.list);
  return list;
}

const useRemoveShopItemByIndex = (index) => {
  const dispatch = useDispatch();
  dispatch({
    type: "REMOVE_SHOP_ITEM",
    payload: idx,
  });
  return;
}

export const {useGetShopList, useRemoveShopItemByIndex};

함수의 내부 구현을 수정하고 함수는 그대로 사용한다고 생각해보자.

여전히 컴포넌트들은 useGetShopList, useRemoveShopItemByIndex 두 함수를 사용하겠지만, 이 함수의 로직은 이전과는 다르다.

수정해야할 코드의 양이 확연하게 줄어든 것을 느낄 수 있을 것이다.

 

물론, 리덕스 훅을 사용했기에 커스텀 훅으로 작성되어야 했으며 다른 라이브러리에서도 훅을 이용하여 구현될 것이라는 보장은 없다.

다만 코드에는 정답이 없고, 추상화를 통한 중복 로직 관리가 훗날의 유지보수에 도움이 되지 않을까 하는 생각에 위와 같은 예제를 만들어 보았다.

Compound Component Pattern

개인적으로 Compound Component Pattern에 대해서 처음 생각해보게 된 계기는 리액트 컴포넌트의 디자인 패턴에 대해 고민하기 시작했을 때 였다.

그중에서 Compound Component Pattern을 보고 @react-navigation/stack을 떠올리게 되었다. @react-navigation/native-stack의 간략한 사용법은 다음과 같다.

import {createNativeStackNavigator} from '@react-navigation/native-stack';
import {Start, End} from "./component";

const Stack = createNativeStackNavigator<CreateWalletStackParamList>();

export default App() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="start" component={Start} />
      <Stack.Screen name="end" component={End} />
    </Stack.Navigator>
  )
}

라우팅을 위해서 Navigator와 Screen을 사용하는데, 이 컴포넌트들은 객체 내부에 포함되어 있었다.

내부 구현을 살펴보면, createNativeStackNavigator에 의해서 반환되는 값 역시 컴포넌트로 구성됨을 알 수 있다.

즉, 컴포넌트 내부에 컴포넌트가 객체의 프로퍼티처럼 존재하는 것이다.

 

아무래도 오픈소스 라이브러리는 복잡하기에, 좀 더 직관적으로 Compound Component Pattern을 이해할 수 있는 예제를 작성해보기로 하였다.

import Main from "./components";

function App() {
  return (
    <Main>
      <Main.TitleName />
      <Main.Counter />
      <Main.TimeStamp />
    </Main>
  );
}

export default App;

Compound Component Pattern은 위와 같이 구성된다.

Main과 관련된 작은 컴포넌트들이 Main의 프로퍼티로 존재하고, Main의 children으로 사용된다.

그렇다면, Main은 어떻게 구현될까?

import React from "react";
import Counter from "./Counter";
import TimeStamp from "./TimeStamp";
import TitleName from "./TitleName";

const Main = ({ children }: { children: React.ReactNode }) => {
  return <div>{children}</div>;
};

Main.Counter = Counter;
Main.TimeStamp = TimeStamp;
Main.TitleName = TitleName;

export default Main;

마치 객체를 사용하듯 Main 컴포넌트에 하위 컴포넌트들이 연결된다.

보기에 깔끔해보이는 코드긴 하지만, 이 패턴은 어떤 장점이 있을까?

 

일단 props drilling을 어느정도 예방할 수 있다.

상태의 위치가 App에 존재할 때 이 상태를 각각 Counter, TimeStamp, TitleName에 전달한다 생각해보자.

(App 하위의 Main을 제외한 다른 컴포넌트들이 공통적으로 이 데이터를 사용한다면, 상태는 필연적으로 App에 위치할 수 밖에 없다)

상태는 App -> Main -> Counter, TimeStamp, TitleName로 거쳐가야 한다.

 

하지만, 위와 같이 Main의 하위에 존재하면 다음과 같이 prop가 전달될 수 있다.

App -> Main.Counter , Main.TimeStamp , Main.TitleName

중간에 한 단게를 건너뛸 수 있는 것이다.

 

또한, 관심사가 일치하는 컴포넌트를 하나로 묶을 수 있다.

컴포넌트가 많아질수록 이를 어떻게 폴더를 나누고 관리, 사용할지 애매한 경우가 있는데 Compound Component Pattern에서는 함께 사용될 컴포넌트가 한 곳에 엮이기 때문에 좀 더 수월하게 컴포넌트를 조립할 수 있을 것이다.

 

🥖 이 부분이 중요하다고 여겨지는데, 폴더 구조를 통해서 컴포넌트들이 어느 페이지에 사용될지 가늠할 수 있지만, 이 방법은 실제 컴포넌트 내부에서 다른 컴포넌트를 포함하는 구조를 가지고 있기에 훨씬 직관적으로 해당 컴포넌트가 어느 페이지에서 사용되는지 파악할 수 있다.

 

🍊 React Native Paper의 DataTable 등 UI 컴포넌트 라이브러리에서 Compound Component Pattern이 자주 발견되는 것 같다.

Render props pattern

render props pattern은 리액트 공식 문서에도 소개 되어있는 방법이다.

https://react.dev/reference/react/cloneElement#passing-data-with-a-render-prop

 

cloneElement – React

The library for web and native user interfaces

react.dev

레거시 API인 cloneElement의 대체 방법으로 소개되어있는데, 공식 문서를 참고한 대략적인 사용법은 다음과 같다.

 

import {useState} from 'react';

export default function ShopList({shopList, renderShopList}) {
  const [isSelected, setIsSelected] = useState(false);
  return (
    <div onClick={() => setIsSelected(!isSelected)}>
      {shopList.map((el) => renderShopList(el, isSelected))}
    </div>
  )
}

ShopList 컴포넌트는 위와 같이 작성되고, 실제 ShopList는 다음과 같이 사용될 수 있다.

import ShopList from './component/ShopList';
import list from './const';

export default App() {
  return (
    <div>
      <h1>Welcome To Shop</h1>
      <ShopList
        shopList={list}
        renderShopList={(item, isSelected) => (
          <div key={item.id}>
            <h2>{item.name}</h2>
            <p>price - {item.price}</p>
            {isSelected ? <h3>🥬</h3> : null}
          </div>
        )}
      />
    </div>
  )
}

기존에 사용하던 패턴과의 차이점으로, 리스트를 렌더링하는 함수가 상위 컴포넌트에서 props의 형태로 내려온다는 점이다.

이 패턴의 의의는 일단 위의 예제에서는 isSelected 변수를 좀 더 명시적으로 사용할 수 있다는 점이다.

실제 구현이 분리되어 있기에 isSelected 변수의 출처를 좀 더 쉽게 찾을 수 있고, 따라서 먼 훗날 isSelected 변수를 추적하기 쉬울 것이다.

 

또한, 대개의 경우 renderShopList의 반환되는 컴포넌트는 분리되는 경우가 많을 것이다.

export default function ListItem({item, isSelected}){
  return (
    <div key={item.id}>
      <h2>{item.name}</h2>
      <p>price - {item.price}</p>
      {isSelected ? <h3>🥬</h3> : null}
    </div>
  )
}

이 경우 render props를 사용하지 않으면, 컴포넌트 트리는 App -> ShopList -> ListItem 으로 이어질 것이다.

당신이 이 새로운 코드를 받아 유지보수를 하기 위해서는 이 긴 트리를 따라가 ListItem에 도달하는 과정을 거쳐야 한다.

반면, render props를 사용한다면 App 하위에 바로 ShopList와 ListItem이 동시에 존재하기에, 좀 더 한눈에 구조를 파악할 수 있을 것이다.

 

🌽 react-virtualized나 React Native의 FlatList가 render props가 사용된 list 최적화 라이브러리이다.

 

컴포넌트 추상화

render props 패턴을 사용하고 나니 눈에 띄는 부분이 있었다.

바로 ListItem인데, ListItem은 단순히 List를 표현하기 위한 하나의 컴포넌트이다.

그렇다면, ShopList 뿐만 아니라, 다른 list의 아이템을 표현하는데도 사용될 수 있는게 아닐까?

export default function ListItem({name, value}){
  return (
    <div key={item.id}>
      <h2>{item.name}</h2>
      <p>{item.value}</p>
    </div>
  )
}

컴포넌트를 조금 수정하고나니, 명확히 알 수 있는 부분이 있다.

위의 컴포넌트는 어떤 상위 컴포넌트에 종속되는 것이 아니라는 점이다. 

그렇다면, 위의 컴포넌트는 다음과 같이 중복되어 사용될 수 있지 않을까?

import ListItem from './component/ListItem';
import {booklist, gamelist, foodlist, movieiist} from './const';

export default App() {
  return (
    <div>
      <h1>Welcome To Shop</h1>
      {booklist.map(book => 
           <ListItem key={book.id} name={book.title} value={book.synopsis} />)}
      {gamelist.map(game => 
           <ListItem key={game.id} name={game.title} value={game.genre} />)}
      {foodlist.map(book => 
           <ListItem key={food.id} name={food.name} value={food.ingredients} />)}
      {movieiist.map(book => 
           <ListItem key={movie.id} name={movie.title} value={movie.comment} />)}           
    </div>
  )
}

위와 같이 여러 종류의 데이터를 표현하는데 ListItem은 사용될 수 있다.

컴포넌트가 어떤 상태나 값, 다른 컴포넌트에 종속되지 않고 추상화되어 있기에 이 컴포넌트는 여러 군데에 걸쳐서 사용할 수 있다.

또한, 모든 리스트를 수정할 때 하나의 컴포넌트만 바라볼 수 있으므로 유지보수에도 편한 점이 있다.

다만, 여러 다양하고 특화된 데이터를 표현하기에는 적합하지 않을 수 있다.

 

객체를 사용한 조건부 Render

리액트에서 조건부 렌더링을 구현하는 방법은 아주 다양하다.

공식문서에서도 다양한 방법에 대해서 소개하고 있다.

https://react.dev/learn/conditional-rendering

 

Conditional Rendering – React

The library for web and native user interfaces

react.dev

if-else, &&, ||, 삼항연산자 (?), switch 문 등 다양한 방법을 사용할 수 있는데 그 중에서 객체를 사용한 조건부 렌더링 방식이 있다.

switch문을 좀 더 간단하게 표현할 수 있으며 코드가 깔끔해진다는 장점을 가지는 방법이다.

 

예제를 만들어보자면, 다음과 같다.

import {Good, Bad, Soso} from './components';

const status = {
  'good': <Good />,
  'bad': <Bad />,
  'soso': <Soso />,
}

export default function TestResult({nowStatus}) {
  return (
    <div>
      <h1>Your Test Result</h1>
      {status[nowStatus]}
    </div>
  )
}

위와 같이 nowStatus의 값에 따라서 조건부로 시험의 결과 창이 렌더링되는 예제를 만들 수 있다.

다만, 이번에는 이 방식의 위험성에 대해서 이야기하고자 한다.

위의 방식이 switch문과 비교했을 때 문제가 발생할 가능성이 있는 부분은 바로 예외처리가 되지 않는다는 점이다.

만약 위의 코드를 switch문을 사용해 작성했다고 보자.

import {Good, Bad, Soso} from './components';

const renderStatus = (status) => {
  switch(status) {
    case 'good': 
          return <Good />;
    case 'bad': 
          return <Bad />;
    case 'soso': 
          return <Soso />;
    default:
          return <p>THERE ARE NO DATA!</p>;
  }
}

export default function TestResult({nowStatus}) {
  return (
    <div>
      <h1>Your Test Result</h1>
      {renderStatus(nowStatus)}
    </div>
  )
}

다른점은 명확하다.

switch문은 default를 통해서 예상치 못한 값에 대한 결과를 리턴한다.

만약 nowStatus값이 'very good'이라면 객체를 사용하였을 땐 undefined가 반환되버린다.

반면, switch문에서는 default를 설정했기에 친절하게 해당 데이터가 없음을 화면에 노출시킬 수 있다.

 

당신이 코드를 완성한 순간에 예외는 발생하지 않고 nowStatus값은 enum으로 설정되어 good, bad, soso만 존재할 수도 있다.

하지만, 실 서비스되는 코드에 완성은 없다.

계속 유지보수되며 새로운 누군가가 코드를 수정할 순간은 오고야 말 것이다.

이때, 대대적인 사이트 개편으로 인해 당신의 후임자가 nowStatus를 수정하여 새로운 값을 마구마구 추가한다면 어떻게 될까?

확실한 건, 객체를 통해 조건부 렌더링을 구현한 경우 이제 그 코드의 결과는 예상을 벗어나게 된다.

 

비단 이 경우를 제외하고도 모든 경우에 대해서 고려할 수 있는 예외처리는 꼭 하는 것이 동료를 위한 작은 배려가 될 것이다.

 

리액트 개발을 하다보면, 느슨하게 결합된 컴포넌트와 같은 문장을 볼 때가 있다.

느슨하게 결합되었다 하면 뭔가 부정적인 의미같지만, 컴포넌트와 훅으로 이리저리 조합하고 쪼개는 리액트의 철학에 맞는 패턴이지 않을까 싶다.

각 컴포넌트의 재사용성을 극대화하고, 개별 컴포넌트의 역할을 분명히 한다면 남들이 봐도 이해하기 쉬운 코드를 작성할 수 있을 것이라고 생각한다.