Published on

상품카드 컴포넌트 개선하기

경영악화로 회사와는 안녕했지만 빠르게 진행되는 스프린트속에서 시간이 주어진다면 꼭 한번 개선해보고 싶었던 컴포넌트가 있었는데 이참에 한번 진행해보려고합니다.

실제 배포가 이루어 지는것은 아니고 이렇게 크고 복잡한 하나의 컴포넌트를 개선해볼 경험은 실무코드말곤 없기 때문에....


기존 컴포넌트 분석

카멜 서비스에는 다양한 형태의 상품카드 디자인이 존재합니다.

  • 가로 스크롤 형 (CardWidth)
    • card-width
  • 목록 그리드 형 (CardGrid)
    • card-grid
  • 리스트형 (CardList)
    • card-list

편의상 괄호안에있는 네이밍으로 앞으로 칭하려고 합니다. 실제 컴포넌트 네임은 좀 다르긴한데 직관적으로 보이기 위함입니다.

첫번째 문제 - 네이밍

우선 짚고 넘어가야할 부분은 네이밍입니다. 컴포넌트 이름안에 list / grid 같은 형태를 정하는 네이밍이 포함되어 있습니다. 이게 뭐가 문제인가 싶을 수 있지만 컴포넌트 내에서 형태를 정하는것이 아닌 사용할땐 부모컴포넌트에서 형태를 정해주고있는 상태입니다.

<Grid>
  {
    data.map(card => (
      <CardGrid {...card} /> 
    ))
  }
</Grid>

그렇기때문에 만약 아래와같이 쓰더라도 문제가 되지않습니다.

<List>
  {
    data.map(card => (
      <CardGrid {...card} /> 
    ))
  }
</List>

두번째 문제 - 중복되는코드

카드에는 종류에따라 보여주는 정보에 약간씩 차이가 있습니다.
어떤 목록에서는 상품에 대한 좀더 디테일한 내용을 보여주는 반면에 어떤 목록에서는 최소한의 기본정보만 보여주기도 합니다.
그러나 상품카드형태라면 동일하게 무조건 포함되어야 하는 정보 또는 기능들도 있습니다.

  • 찜 토글 기능
  • 상품상세이동
  • 상품에 대한 라벨표시

이러한 기능들이 각각의 컴포넌트 내에서 개별적으로 처리되고있기때문에 만약 상품카드에 기능을 추가하거나 새로운 UI 를 추가 또는 유지보수를 해야할때 서로 다른 타입의 컴포넌트3개를 기본적으로 열어두고 진행해야합니다.

세번째문제 - 유지보수의 어려움

중복되는 코드로인해서 여러개의 파일을 열어서 모두 수정해야하는 불편함을 제외하고도 컴포넌트 자체의 유지보수에 대한 어려움을 갖고 있습니다.
중간중간 의미를 알기 어려운 함수또한 존재하고 부모에게서 많은 props 를 전달받아서 내부에서 분기처리를 통해서 화면을 렌더링하거나 동작을 실행하고 있습니다.
또 코드라인또한 길어 무언갈 수정하려고 할때면 그 긴 코드중에 필요한 부분을 찾아야 하고 또 의존성을 갖고 있는 다른 함수또한 찾아서 확인해야하는 불편함이 존재합니다.

네번째 - useEffect 의 남용

해당 컴포넌트는 상품의 정보는 카드형태로 보여주고있습니다.

  • 상품이미지
  • 상품이름
  • 상품가격
  • 상품 브랜드
  • 상품의 제목
  • 상품이 등록 또는 업데이트된 시간
  • 카테고리
  • 사이즈

많아보이긴하지만 일반적으로 상품에 대한 정보를 사용자에게 보여주는데 필요한 기본적인 정보들입니다. 이 정보들을 처리하기 위해서 각 컴포넌트마다 useEffect 가 5개씩 작성되어 있습니다.

리액트 공홈에서 useEffect 에 대한 내용을 찾아보면 필요하지 않을 수도 있다고 말합니다.

렌더링을 위해 데이터를 변환하는데 effect 가 필요하지 않습니다. 사용자 이벤트를 처리하는데 Effect 가 필요하지 않습니다.

상품카드를 보여주는 컴포넌트에서는 저 두가지에 모두 해당되는 상태의 Effect 가 작성되어 있습니다.

외부시스템과 동기화 할때 필요합니다. (채팅, jQuery 위젯 등)

다섯번째 - 분기처리

!hideLabel &&
(!isAuthSeller || isAuthProduct) &&
(productSellerType === 4 ||
  siteId === 34 ||
  productSellerType === 3) && <Component />

하나의 컴포넌트에 대한 노출 조건을 처리하기위한 조건문입니다.
딱 봤을때 한눈에 어떤 조건일때 컴포넌트를 표시하게되는지 알기 너무 어렵습니다. 저 컴포넌트 하나의 노출을 이해하기위해서는 해당하는 조건들에 대해서 어떻게 처리되는 조건들인지 하나하나 확인해야지만 비로소 저 컴포넌트를 수정할 수 있습니다.

{!hideLabel &&
  (!isAuthSeller || isAuthProduct) &&
  productSellerType !== 4 &&
  siteId !== 34 && (
    <Flexbox gap={4} customStyle={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}>
      {isAuthSeller ? (
        <Component1 />
      ) : (
        <Component2 />
      )}
      {isAuthProduct && <LabelComponent />}
    </Flexbox>
  )}

여기는 저 복잡한 조건을 안고 그 안에서 또 다른 조건이 추가로 더 존재합니다.

이러한 분기 처리들이 하나의 컴포넌트안에서 여러곳에 존재하고 있기 때문에 서비스 특성상 중요한 컴포넌트와 로직을 갖고 있음에도 불구하고 쉽게 유지보수하거나 변경을 주기가 어렵습니다.

진행한다 하더라도 사이드이펙트가 발생하거나 누락되는 케이스가 빈번하게 발생할 수 밖에 없는 구조로 볼 수 있습니다.

여섯번째 - 재활용이 어렵다.

서비스 특성상 상품의 카드 형태로 보여주는 신규 페이지나 기능은 언제든지 발생할 수 있습니다. (기획전, 이벤트 등)
너무 많은 조건과 기능을 포함하고 있다보니 입맛에맞춰서 재활용하기가 어렵습니다. 결국 새로운 카드컴포넌트가 생겨나게 됩니다.

찜한목록 / 최근 본 상품목록 그리고 내상점보기 에서 사용하고있는 컴포넌트가 그렇습니다. 빠르게 페이지는 만들어야 하는데 기존 컴포넌트를 수정해서 쓰자니 신규개발 페이지 뿐만 아니라 기존에 사용중인 페이지에서의 영향도까지 신경써야하는 상황이 있었습니다.

자주쓰이고 핵심 컴포넌트이지만 재활용하거나 확장 유지보수는 어렵다는 생각이 들었고 이러한 문제점들이 있기때문에 재구성하는데 있어서 아주 좋은 교재이면서 블로그 작성용으로도 딱이란 생각이 들었습니다.


개선을 시작해보기

구조잡기

저는 컴포넌트를 좀더 작게 나눈 관점에서 보려고 합니다. 기존에는 큰 컴포넌트 하나로 전체 레이아웃및 상태를 체크하려고 하다보니 비대해져버려 문제가 되었었는데 이 부분과 네이밍 부분을 개선하기위해 구조를 잡으려고 합니다.

  • 상품을 보여주는 이미지
  • 상품의 정보를 나타내는 컨텐츠

UI 기준으로 봤을때 크게 이렇게 두가지로 좀더 나눌 수 있을것같습니다.
이미지는 그냥 이미지만 보여주면 끝이라 생각해서 왜 나누나 싶을텐데 이미지 안에서 크롤링해온 플랫폼의 로고를 보여주거나 특정 조건을 만족하면 인증 라벨을 붙여주거나 오버레이를 보여주는 등 이미지내에서도 비즈니스의 로직이 일부 포함되기 때문에 분리를 생각했습니다.

image-ui
# 이미지에 포함되는 로직
1. 직각형 이미지인지 라운드형 이미지인지
2. 기본적으로는 플랫폼 로고 노출 형
3. 특정한 케이스의 경우엔 플랫폼 로고 노출 안함
4. 인증 조건을 만족하면 인증 라벨이 붙음
5. 상품의 상태에 따라 오버레이가 보여짐 (예약중, 판매완료, 삭제, 숨김 등)

일단 여기서는 이미지가 없을때나 이미지가 로드중일때 같은 케이스는 일반적이라 제외하고 해당 컴포넌트에서만 포함될 케이스만 다룹니다.

product-detail
# 상품정보
1. 기본정보 (브랜드, 타이틀, 가격, 등록된 시간 및 날짜)
2. 옵션정보
  - 거래 또는 등록 및 판매 지역
  - 끌어올리기 상태
  - 좋아요, 채팅진행중 카운트
  - 사이즈
  - 가격다운, 인기모델, 감정완료 등 라벨표시

이렇게 정리하고보니 왜 이렇게 많은 코드를 다 갖고 있었는지 약간 의문이 드는데 서버에서 내려준 데이터를 프론트단에서 처리하는 부분이 일부 존재해서 그런것같습니다.

# 기타 (찜하기, 더보기)
- 상품목록이 노출되는 영역에 따라서 찜하기의 위치가 조금씩 다릅니다.
- 내 상점에서 상품 카드를 볼때는 찜하기 대신에 더보기가 존재합니다.
card-layout

조건에 따라 보여줄것 보여주지않을것 그리고 레이아웃 찜하기의 위치 등이 다 다른것처럼 보이지만 하나의 규칙은 뽑아보자면 가로형인지 세로형인지로 구분할 수 있을것같습니다.

그래서 이미지 영역과 상세정보 영역으로 컴포넌트를 나누고 조합하는 형태를 구상하였습니다.

<ProductCardImg />
<ProductCardDetail />

이렇게 작성했을경우 상품 카드 형태의 레이아웃 구조를 부모컴포넌트에서 컨트롤 할 수 있게 됩니다.

<List>
  {
    products.map(product => (
      <Flexbox direction="column">
        <ProductCardImg {...product} />
        <ProductCardDetail {...product} />
      <Flexbox>
    ))
  }
</List>

ProductCardImg 컴포넌트

먼저 해당 컴포넌트에 필요한 타입을 정의해보겠습니다.

interface ProductCardImgProps {
  productImg: string;
  productAlt: string;
  platformImg?: string;
  platformAlt?: string;
  platform?: string;
  certificationProduct?: boolean;
  round?: boolean;
  overayType?: '예약중' | '판매완료' | '숨김';
}

상품이미지는 필수요소이고 나머지는 조건에 따라 노출이 달라지는 항목들입니다. 기존에는 CardWidth 컴포넌트 내에서 플랫폼 노출 및 인증 라벨 노출처리를 다 하고 있었고 불필요하게 useEffect 를 사용하므로써 비효율적인 렌더링 방식을 사용하고 있었습니다.

useEffect(() => {
  setCertificationProduct(legitStatus === 30 && result === 1);
}, [productLegit]);

{!hideLabel && certificationProduct && (
  <Flexbox>
    <Flatform />
    <LabelText text="인증"/>
  </Flexbox>
)}

저는 해당 컴포넌트에서는 UI 에 필요한것만 받아서 처리하고 비즈니스 관련 로직은 따로 분리해서 처리하려고 합니다.

ProductCardDetail 컴포넌트

interface ProductCardDetailProps {
  brandName: string;
  productTitle: string;
  productPrice: number;
  createdDate?: string;
  updatedDate?: string;
  likeCount?: number;
  chatCount?: number;
  productSize?: string;
  location?: string;
  labels?: string[];
}

상품의 상세정보 컴포넌트에도 타입을 정해 주었습니다.

고민포인트
비즈니스 로직을 분리하기위해서 커스텀훅을 사용하려고 생각하고 있는데 useProduct 라는 하나의 훅으로 상품정보에 대한 처리를 모두 위임할것인지 아니면 컴포넌트를 나눈것처럼 useProductImg / useProductDetail 로 두개의 커스텀 훅을 만들어서 처리할지 였다.
고민결과 두개로 나누기로 결정 이유는 상품카드뿐만아니라 상품상세 페이지나 채팅창 그리고 결제페이지등에서도 결국 상품정보가 나오게 되는데 이에 대한 처리도 한곳에서 관리 할 수 있겠다 라는 생각이 들었습니다.
그리고 이왕이면 이미지에대한 처리와 정보에 대한 처리를 구분해서 두고 싶기도 했습니다.

나중에 상품상세와 카드의 노출 정보나 처리가 완전히 달라진다고 하면 또 분리를 고민해 봐야겠지만 현재로서는 동일하게 처리되는 부분이 많이 있기 때문에 같이 사용할 수 있다고 판단하였습니다.

이렇게 나눈 구조를 적용해보면 아래와 같은 그림이 나올것같습니다

function ProductCardImg(product) {
  const {
    productImg,
    productAlt,
    platformImg,
    platformAlt,
    platform,
    certificationProduct,
    round,
    overayType,
  } = useProductImg(product)

  return (
    ...
  )
}

function ProductCardDetail(product) {
  const {
    brandName,
    productTitle,
    productPrice,
    createdDate,
    updatedDate,
    likeCount,
    chatCount,
    productSize,
    location,
    labels,
  } = useProductDetail(product)

  return (
    ...
  )
}
// 새로 올라온 상품 목록 페이지
function NewUpdateProducts() {
  const [data: products] = useQuery()
  return (
    <Grid gird={2}>
      {
        products.map(product => (
          <FlexBox direction="horizontal">
            <ProductCardImg product={product} />
            <ProductCardDetail product={product} />
          </FlexBox>
        ))
      }
    <Grid>
  )
}

// 찜한 상품 목록 페이지
function NewUpdateProducts() {
  const [data: products] = useQuery()
  return (
    <List>
      {
        products.map(product => (
          <FlexBox direction="vertical">
            <ProductCardImg product={product} />
            <ProductCardDetail product={product} />
          </FlexBox>
        ))
      }
    <List>
  )
}

// 페이지 내의 가로스크롤형태
function NewUpdateProducts() {
  const [data: products] = useQuery()
  return (
    <WidthScrollList>
      {
        products.map(product => (
          <FlexBox direction="horizontal">
            <ProductCardImg product={product} />
            <ProductCardDetail product={product} />
          </FlexBox>
        ))
      }
    <WidthScrollList>
  )
}

처리된 내용

  1. 컴포넌트 네이밍 문제 해결
    기존에 컴포넌트 네이밍에 스타일이 의존되었었 더라면 바뀐 구조는 부모에서 필요에 의해서 레이아웃을 변경할 수 있는 구조로 바뀌었습니다.

  2. 중복되는 로직
    커스텀훅으로 로직을 분리함으로써 컴포넌트는 오로지 무엇을 언제 어떻게 보여줄지만 신경쓸 수 있게 되었습니다. 또한 훅으로 분리한 로직은 카드뿐만아니라 상품 상세 페이지에서도 활용될 수 있습니다.

  3. 유지보수 관점
    기존에는 CardWidth / CardGrid / CardList (기타 카드형태의 컴포넌트가 별도로 더 있음...) 등 여러개의 컴포넌트를 모두 열어서 확인하고 수정을 진행해야 했습니다. 뷰영역과 로직 영역이 섞여있고 일부로직은 부모컴포넌트에서 수행해서 내려주기도 했습니다. 변경된 구조는 관심사가 분리되어 디자인 과 로직 수정의 구분에 따라 확인해야하는 파일이 명확해 졌습니다.

  4. useEffect 최소화
    해당 카드컴포넌트에서 사실상 유저와 상호작용을 통해 변경이 발생하는 부분은 찜하기 뿐입니다. 때문에 필요한 Effect 도 하나면 충분합니다.

  5. 분기처리
    이 부분은 예전에 상세페이지의 CTA 버튼 컴포넌트를 개선하면서 만들어 두었던 커스텀훅으로 해결할 수 있었습니다.

  6. 재활용
    기존 컴포넌트는 처음 상품카드 디자인 가이드가 잡힐때 유연성이나 확장성 고려가 안된채로 디자인대로만 구분된 컴포넌트 였습니다. 그래서 사소한 기능추가나 변경사항을 적용하려고하면 또다른 카드 컴포넌트를 만들어야만 했습니다. 변경된 구조에서는 카드형태의 변형이 일어나더라도 보여주는것에만 집중하여 재활용 할 수 있게 되었습니다.

커스텀 훅

커스텀훅을 이미지와 상세로 작성하고 이를 각각 이미지와 상세 컴포넌트 내에서 호출해서 사용하고 있습니다.
찜하기가 추가되었을경우 케이스에 따라 이미지 또는 디테일에 보여지게 될텐데 사용자 이벤트 발생시 해당 컴포넌트만 리렌더링 수행될 수 있도록 하려고 분리한 부분입니다.

해당 컴포넌트들이 product 의 값에 의존하게 되어 재사용성 측면에서 불리하다 라고 생각할수도있는데 서비스 특성상 상품카드 형태의 디자인에는 product 의 타입속성값이 모두 기본적으로 포함되어 있습니다.


아직 끝나지 않았다

현재 구조가 완벽하게 모든것을 커버할 수는 없다고 생각합니다.
경우의수가 무수히 있기때문에 ..... 예를들어 카드의 상품정보에서

1. 가격을 맨위로 보여주고싶다
2. 그러나 전체 카드가 아닌 특정 섹션 또는 페이지에서만 가격을 강조하고싶다

이러한 요구사항이 있을 수 있습니다.
아마 전 이렇게 대응할것같습니다.

특정 페이지 또는 영역만 그렇게 보여준다면 사용자에게 주는 서비스에 대한 일관성이나 신뢰도가 떨어질것같습니다.   
만약 정말 저런 변경이 필요하다면  
A/B 테스트를 우선 진행해서 데이터를 확보한 후에   
사용자 경험에 대한 목표치가 나온다면   
차라리 전체 카드 디자인에 대한 변경을 하는것이 좋을것같습니다.

만약 결국 요구사항을 들어줘야 한다면 몇가지 전제되어야 할 사항들이 있어야할것같습니다.

  1. 이런 변경이 자주 있을것인지?
  2. 일회성 이벤트페이지 또는 영역에 대한 적용인지?

자주 발생할것같다면 ProductCardDetail 에 대한 컨텐츠 배치나 디자인을 부모에서 정할 수 있도록 컴포넌트의 변경이 필요할것같습니다.
2번이라면 별도의 컴포넌트를 만들어서 처리 후 종료시점에 컴포넌트를 같이 정리하는 방향으로 생각해볼 수도 있을것같습니다.

1번으로 인해 순서나 디자인이 변경되더라도 긴코드를 읽어내려가거나 로직을 신경쓰지 않고 변경할 수 있는 수준이 잡혀있다라고 생각합니다.


추가되어야 할것

글로작성하다보니 몇개 구현을 자르긴했는데 찜하기 아이콘의 위치에 대한 고민이 좀 필요합니다. 경우에 따라서 이 찜하기 아이콘은 image 컴포넌트에 포함되기도하고 detail 컴포넌트에 포함되기도 합니다.

가장 베스트는 디자이너와 기획자와 상의 후 통일성있는 UI 로 가져가는것이 개발자 입장에선 베스트이지만 만약 유저의 경험향상과 페이지의 특성을 반영하기 위한 조치라면 적용해야하는 부분이기에 꼭 필요한 고민입니다.