ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [ Project ] 컴포넌트 설계에 대한 고민 (Feat. 합성 컴포넌트)
    Project 2023. 9. 10. 17:03

    - 최초 기획 UI

    사진 속 Table 컴포넌트는 앞으로 다양한 모습으로 사용됩니다.

    사진 속 UI처럼 가장 처음에 기획했던 Table 컴포넌트는 텍스트로만 이루어져 있습니다.

    중간중간 기획에 대한 아이디어가 더해지면서 UI가 조금씩 다른 컴포넌트가 필요했고 

    자연스럽게 컴포넌트의 확장성, 재사용성에 대해서 고민을 하게 되었습니다. 

     

    - 초기 코드

    Table 컴포넌트는 Header, Content, Pagination 세 가지 컴포넌트로 구성되어 있습니다.

    // /components/common/table/Table.tsx
    // 생략
    const Table = ({...props}) => {
      if (data.length === 0) return <EmptyList />;
      return (
        <S.Container>
          <Header {...props}>
          <Content {...props}/>
          <Pagination {...props}/>
        </S.Container>
      );
    };
    
    export default Table;

     

    Content 컴포넌트의 초기 코드입니다.

    페이징 된 데이터 CURRENT_DATA가 첫 번째 map으로 돌면서 Content 하나의 열을 구성하고,

    두 번째 map을 돌면서 해당 열의 Table Cell에 들어갈 UI를 나타냅니다.

    // /components/common/table/Content.tsx
    // - - 생략
    const Content = ({...props}) => {
      const CURRENT_DATA = data.slice(crntPage! * CONTENT_LIMIT, crntPage! * CONTENT_LIMIT + CONTENT_LIMIT);
      return (
        <S.ContentContainer>
          {CURRENT_DATA.map((e, idx) => (
            <S.ContentWrapper>
              {category.map((elem, inner_idx) =>
                  <div key={inner_idx}>{e[elem[1]]}</div>
                )
              )}
            </S.ContentWrapper>
          ))}
        </S.ContentContainer>
      );
    };

     

    Usage 

    Table 컴포넌트의 사용은 이와 같습니다.

    - type : 내부 분기처리를 위한 props

    - category : Header의 제목에 해당하는 데이터

    - widthRatio : 각 열의 너비 비율

    - data : Table 컴포넌트에 나타낼 데이터

    <Table
      type={"study"}
      category={[["스터디", "name"],["소개", "about"],["인원", "capacity"],["스터디 장", "leader"],["랭킹", "xp"]]}
      widthRatio={[1, 2, 1, 1, 1]}
      data={userStudyList.data.data}
    />

     

    - 🚨 변화하는 Table Cell 🚨

    위 세 개의 컴포넌트는 Table 컴포넌트와 비슷한 UI를 나타내는 컴포넌트입니다.

    차이점은 초기 구현한 Table Cell에는 오직 Text로 되어 있지만 추가 요구사항에는 Text뿐 아니라 Element가 들어가야 하는 상황입니다.

    이를 구현하기 위해서 새로운 컴포넌트를 만들기에는 기존 Table 컴포넌트와 중복되는 코드가 많기에 

    충분히 Table 컴포넌트에 코드를 더하여 재사용할 수 있을 것 같습니다.

     

    일단 해결하기

    위 컴포넌트를 기존 코드를 뒤엎지 않고 일단 구현해 보았습니다.

    삭제 버튼을 위해 두 번째 map 내부에서 분기처리 했습니다.

    elem[1]을 기준으로 분기처리를 진행했고, 삭제 버튼까지 잘 나타내도록 해결할 수 있었습니다.

    // /components/common/table/Content.tsx
    // - - 생략
    const Content = () => {
      return (
        <S.ContentContainer>
          {CURRENT_DATA.map((e, idx) => (
            <S.ContentWrapper>
              {category.map((elem, inner_idx) =>
    			elem[1] === "remove" ? (
                  <RemoveProblemButton key={inner_idx} idx={idx + 1} />
                ) : (
                  <div key={inner_idx}>{e[elem[1]]}</div>
                )
              )}
            </S.ContentWrapper>
          ))}
        </S.ContentContainer>
      );
    };

     

    해결은 했습니다.  

    하지만 추가적으로 조금씩 기능과 UI가 변경된다면 ?

    CallBack지옥처럼 분기지옥을 마주할 것입니다.

    (알면서도 , 일단 구현이 급급해서 분기지옥을 체험할 수 있었습니다.)

    아래 Content 코드는 분기처리 코드에 집중하기 위해서 나머지 코드는 다 삭제한 상태의 코드입니다.

    원래는 더 많은 코드들이 이곳에 응집과 의존되어 있었습니다.

            <S.ContentWrapper>
              {category.map((elem, inner_idx) =>
                elem[1] === "request" ? (
                  <RequestStatus/>
                ) : elem[1] === "study_invite" ? (
                  <InviteAcceptButton/>
                ) : elem[1] === "user_invite" ? (
                  <InviteAcceptButton/>
                ) : elem[1] === "remove" ? (
                  <RemoveProblemButton />
                ) : (
                  <div key={inner_idx}>{e[elem[1]]}</div>
                )
                -
                상황에 따른 분기 코드
                -
                -
              )}
            </S.ContentWrapper>

     

    해당 컴포넌트는 앞으로 구현이 추가될 때마다 적절하게 대응하지 못합니다.

    쌓여가는 props와 내부 분기처리로 코드의 복잡도가 증가하고 갈수록 컴포넌트의 역할은 모호해질 것입니다.

    따라서 주도권을 내부에서 가지는 것이 아닌 주도권을 외부에 넘겨주어야 합니다. 

    외부에 주도권을 넘겨줌으로써 구현이 추가되어도 유연하게 확장하며 대응할 수 있습니다. 

     

    - 합성 컴포넌트 도입

    합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미합니다. - 합성 컴포넌트로 재사용성 극대화하기 Kai님의 글 중

     

    합성 컴포넌트 패턴은 컴포넌트의 역할이 잘 분리되고 높은 재사용성을 가지는 장점이 있습니다.

    이러한 장점으로, 컴포넌트의 변동사항에서도 유연하게 대응할 수 있습니다.

     

    - 메인 컴포넌트와 서브 컴포넌트

    • 메인 컴포넌트 : 서브 컴포넌트를 묶어주는 Wrapper 성격의 컴포넌트입니다.
    • 서브 컴포넌트 : Table 컴포넌트를 구성하는 컴포넌트입니다.

     

    메인 컴포넌트 구현

    메인 컴포넌트에는 내부에 공통적으로 필요한 상태 보관하기 위한 ContextAPI Provider로 구성되어 있습니다.

    const TableContainer = ({ children, data }: ITableProps) => {
      return (
        <S.Container>
          <TableProvider data={data}>{children}</TableProvider>
        </S.Container>
      );
    };

     

    Provider에는 data와 Table의 데이터 페이징을 위한 상태가 담겨있습니다.

    export const TableProvider = ({ children, data }) => {
      const [crntPage, setCrntPage] = useState(0);
    
      return <TableContext.Provider value={{ crntPage, setCrntPage, data }}>{children}</TableContext.Provider>;
    };

     

    메인 & 서브 컴포넌트를 묶어서 export

    서브 컴포넌트에는 Header, ContentContainer, ContentRow, Pagination으로 구성되어 있습니다.

    import TableContainer from "./TableContainer";
    import Header from "./Header";
    import ContentContainer from "./ContentContainer";
    import ContentRow from "./ContentRow";
    import Pagination from "./Pagination";
    
    export const Table = Object.assign(TableContainer, {
      Header: Header,
      ContentContainer: ContentContainer,
      ContentRow: ContentRow,
      Pagination: Pagination,
    });

     

    USAGE

    const MemberTable = ({ data, category, widthRatio }: any) => {
      const { navigatePage } = useNavigation();
      const renderFieldContent = (field, item, index) => {
        switch (field[1]) {
          case "nickname":
            return (
              <S.UserInfoContainer key={index}>
                <Image />
                <span>{item[field[1]]}</span>
              </S.UserInfoContainer>
            );
          default:
            return <div key={index}>{item[field[1]]}</div>;
        }
      };
    
      return (
        <Table data={data}>
          <Table.Header category={category} />
          <Table.ContentContainer>
            {(item, index) => (
              <Table.ContentRow
                onClickMethod={() => navigatePage({ type: "member", id: item.id })}
                {(field, index) => renderFieldContent(field, item, index)}
              </Table.ContentRow>
            )}
          </Table.ContentContainer>
          <Table.Pagination />
        </Table>
      );
    };
    
    export default MemberTable;

     

    초기 기존 코드에서는 Table Cell에서의 추가적인 구현 사항에 대한 대응을 props와 Content 내부에서 분기처리로 했다면

    이후 코드에서는 Table.ContentRow컴포넌트를 통해 넘겨주고 있습니다.

    이로써 Table Cell에서의 추가 요구사항을 유연하게 대처할 수 있습니다.

     

    어떻게 더 유연하게 설계할 수 있을까 , 의존성을 낮출 수 있을까, 역할을 명확하게 할 수 있을까, 어디까지 추상화를 해야 할까 등 

    컴포넌트 설계에 대한 고민을 하고 개선하는 과정을 거치면서 컴포넌트 설계의 중요성을 체감할 수 있었습니다.

     

    - Reference

    https://www.youtube.com/watch?v=edWbHp_k_9Y

    https://fe-developers.kakaoent.com/2022/221020-component-abstraction/

    https://fe-developers.kakaoent.com/2022/220731-composition-component/

     

     

     

     

Designed by Tistory.