HOC 패턴으로 Form 리팩토링하기

지혜·2024년 8월 15일
0
기존코드
return (
  <ScrollArea className='pb-4 h-full'>
    <h2 className='font-medium text-xl'>
      외관
    </h2>
    <Separator className="my-4" />
    <div className='flex flex-col gap-y-4'>
      <div className='flex justify-between items-center'>
        <div>
          <h3 className='text-base'>로고</h3>
          <p className='text-muted-foreground text-sm'>하우스를 대표하는 이미지를 등록하세요.</p>
        </div>
        <label
               htmlFor='logo_image'
               className='relative flex justify-center items-center mt-2 border rounded-md w-32 h-16 text-muted-foreground overflow-hidden'
               >
          <input
                 id='logo_image'
                 accept='image/*,.jpeg,.jpg,.png'
                 type='file'
                 onChange={handleImageUpload}
                 className='hidden'
                 />
          {style.logo_image ? (
          <Image
                 src={getPublicUrl(`style/${style.logo_image}`)}
                 alt="logo_image"
                 fill
                 className='object-contain'
                 />
          ) : (
          <p className='flex justify-center items-center gap-x-2 text-sm'>
            <Plus size={16} />
            업로드
          </p>
          )}
        </label>
      </div>
      <div className='flex justify-between items-center'>
        <div>
          <h3 className='text-base'>배경 이미지</h3>
          <p className='text-muted-foreground text-sm'>하우스 배경 이미지를 등록하세요.</p>
        </div>
        <div className='relative text-right'>
          <label
                 htmlFor='bg_image'
                 className='relative flex justify-center items-center mt-2 border rounded-md w-32 h-16 text-muted-foreground overflow-hidden dropzone'
                 >
            <input
                   id='bg_image'
                   accept='image/*,.jpeg,.jpg,.png'
                   type='file'
                   onChange={handleImageUpload}
                   className='hidden'
                   />
            {style.bg_image ? (
            <Image
                   src={getPublicUrl(`style/${style.bg_image}`)}
                   alt="bg_image"
                   fill
                   className='object-cover'
                   />
            ) : (
            <p className='flex justify-center items-center gap-x-2 text-sm'>
              <Plus size={16} />
              업로드
            </p>
            )}
          </label>
        </div>
      </div>
      <div className='flex justify-between items-center'>
        <div>
          <h3 className='text-base'>배경색</h3>
          <p className='text-muted-foreground text-sm'>배경 이미지가 등록되어 있을 경우 적용되지 않습니다.</p>
        </div>
        <div className='relative text-right'>
          <ColorPickerButton color={style.bg_color} handleChange={(v) => handleUpdateStyle('bg_color', v)} />
            </div>
        </div>
        <Separator className="my-2" />
        <div className='flex justify-between items-center'>
          <div>
            <h3 className='text-base'>메인 색상</h3>
            <p className='text-muted-foreground text-sm'>하우스의 메인 색상을 변경해보세요.</p>
          </div>
          <div>
            <Select defaultValue={style.color} onValueChange={(v) => handleUpdateStyle('color', v)}>
              <SelectTrigger className='w-40'>
                <SelectValue placeholder='색상을 선택하세요.' />
              </SelectTrigger>
              <SelectContent>
                <SelectGroup>
                  {colorArr.map((item) => (
                  <SelectItem key={item.name} value={item.name}>
                    <div className='flex items-center'>
                      <span className='inline-block mr-2 rounded-full w-5 h-5 shrink-0' style={{ background: item.color }}></span>
                      <span>{item.name}</span>
                    </div>
                  </SelectItem>
                  ))}
                </SelectGroup>
              </SelectContent>
            </Select>
          </div>
        </div>
        <div className='flex justify-between items-center'>
          <div>
            <h3 className='text-base'>모드</h3>
            <p className='text-muted-foreground text-sm'>하우스 모드를 변경해보세요.</p>
          </div>
          <div>
            <Select defaultValue={style.mode} onValueChange={(v) => handleUpdateStyle('mode', v)}>
              <SelectTrigger className='w-40'>
                <SelectValue placeholder='모드를 선택하세요.' />
              </SelectTrigger>
              <SelectContent>
                <SelectGroup>
                  <SelectItem value='light'>라이트 모드</SelectItem>
                  <SelectItem value='dark'>다크 모드</SelectItem>
                </SelectGroup>
              </SelectContent>
            </Select>
          </div>
        </div>
        <Separator className="my-2" />
        <div className='flex justify-between items-center'>
          <div>
            <h3 className='text-base'>박스 테두리</h3>
            <p className='text-muted-foreground text-sm'>박스 테두리 스타일을 설정하세요.</p>
          </div>
          <div>
            <Select defaultValue={style.box_style.border} onValueChange={(v) => handleUpdateBoxStyle('border', v)}>
              <SelectTrigger className='w-40'>
                <SelectValue placeholder="수치를 선택하세요." />
              </SelectTrigger>
              <SelectContent>
                <SelectGroup>
                  <SelectItem key='line' value='line'></SelectItem>
                  <SelectItem key='shadow' value='shadow'>그림자</SelectItem>
                  <SelectItem key='none' value='none'>테두리 없음</SelectItem>
                </SelectGroup>
              </SelectContent>
            </Select>
          </div>
        </div>
        <div className='flex justify-between items-center'>
          <div>
            <h3 className='text-base'>박스 배경</h3>
            <p className='text-muted-foreground text-sm'>박스 배경의 투명도를 설정하세요.</p>
          </div>
          <div>
            <Select defaultValue={style.box_style.opacity} onValueChange={(v) => handleUpdateBoxStyle('opacity', v)}>
              <SelectTrigger className='w-40'>
                <SelectValue placeholder="수치를 선택하세요." />
              </SelectTrigger>
              <SelectContent>
                <SelectGroup>
                  {opacityArr.map((item) => (
                  <SelectItem key={item} value={String(item)}>
                    {item * 100}%
                  </SelectItem>
                  ))}
                </SelectGroup>
              </SelectContent>
            </Select>
          </div>
        </div>
        <div className='flex justify-between items-center'>
          <div>
            <h3 className='text-base'>테두리 둥글게</h3>
            <p className='text-muted-foreground text-sm'>박스 테두리를 둥글게 변경해보세요. 숫자가 클수록 둥글고 작을수록 각집니다.</p>
          </div>
          <div>
            <Select defaultValue={style.box_style.radius} onValueChange={(v) => handleUpdateBoxStyle('radius', v)}>
              <SelectTrigger className='w-40'>
                <SelectValue placeholder="수치를 선택하세요." />
              </SelectTrigger>
              <SelectContent>
                <SelectGroup>
                  {radiusArr.map((item) => (
                  <SelectItem key={item} value={String(item)}>
                    {String(item)}
                  </SelectItem>
                  ))}
                </SelectGroup>
              </SelectContent>
            </Select>
          </div>
        </div>
      </div>
      </ScrollArea>
    );

커스텀 훅을 사용해서 비즈니스 로직과 뷰 로직을 분리했지만 아직 좋지 않은 냄새가 나는 것 같습니다...😢 냄새의 원인을 살펴보면 아래와 같은 문제점이 있습니다.

현재 코드의 문제점

  1. 비슷한 구조의 UI 요소들이 중복되어 사용되고 있다.
  2. 모든 설정 항목이 하나의 컴포넌트 내에 나열되어 있다.
  3. 상태 관리가 각 항목별로 이루어지고 있어서, 상태 업데이트가 분산되어 있다.

UI를 살펴보면 항목별로 모두 비슷한 형태의 라벨을 가지고 있고 이미지 업로드, 색상 선택, 스타일 설정 등의 차이만 존재하는 것을 확인할 수 있습니다.

리팩토링 계획

  1. 공통된 UI 요소를 별도의 컴포넌트로 분리
  2. HOC를 정의하여 공통적인 설정 UI를 감싸는 패턴을 적용
  3. 설정 항목에 대한 데이터를 별도로 정의하여 UI 컴포넌트에서 데이터를 분리

❓고차 컴포넌트(Higher-Order Component, HOC)란?
리액트에서 재사용 가능한 컴포넌트를 생성하기 위한 패턴입니다. 간단히 말해, HOC는 다른 컴포넌트를 인수로 받아서, 그 컴포넌트를 확장하거나 수정한 새로운 컴포넌트를 반환하는 함수입니다.

코드 쪼개보기

<div className='flex justify-between items-center'>
  <div>
    <h3 className='text-base'>모드</h3>
    <p className='text-muted-foreground text-sm'>하우스 모드를 변경해보세요.</p>
  </div>
  <div>
    <Select defaultValue={style.mode} onValueChange={(v) => handleUpdateStyle('mode', v)}>
      <SelectTrigger className='w-40'>
        <SelectValue placeholder='모드를 선택하세요.' />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectItem value='light'>라이트 모드</SelectItem>
          <SelectItem value='dark'>다크 모드</SelectItem>
        </SelectGroup>
      </SelectContent>
    </Select>
  </div>
</div>

원본 코드를 쪼개서 보면 위와 같은 형태의 요소가 반복되어 사용되고 있는 것을 알 수 있습니다. SettingItem고차 컴포넌트(HOC)로 만들어 컴포넌트를 전달받도록 리팩토링해봅시다.

// withSettingItem

const withSettingItem = <P extends object>(
  WrappedComponent: React.ComponentType<P>
) => {
  const ComponentWithSettingItem = (props: P & SettingItemProps) => {
    const { title, description, ...restProps } = props;
    return (
      <div className='flex justify-between items-center'>
        <div>
          <h3 className='text-base'>{title}</h3>
          <p className='text-muted-foreground text-sm'>{description}</p>
        </div>
        <div>
          <WrappedComponent {...(restProps as P)} />
        </div>
      </div>
    );
  };
  return ComponentWithSettingItem;
};

withSettingItem HOC는 Component를 전달받아, 이 컴포넌트에 titledescription을 포함한 레이아웃을 제공하는 역할을 하고, 실제 콘텐츠는 전달받은 컴포넌트가 처리하도록 할 것입니다.

❓ 전달받는 컴포넌트의 props 타입을 유지하기 위해 제네릭을 사용하여 Component의 props 타입을 자동으로 추론할 수 있도록 합니다.


항목별 컴포넌트 작성

이제 withSettingItem HOC를 사용하여, 각 설정 항목을 정의할 수 있습니다.

이미지 업로드 컴포넌트

export const ImageUpload = ({ id, src, onChange }: ImageUploadProps) => (
  <label htmlFor={id} className='...'>
    <input id={id} type='file' onChange={onChange} className='hidden' />
    {src ? (
      <Image src={src} alt={id} fill className='object-contain' />
    ) : (
      <p className='flex justify-center items-center gap-x-2 text-sm'>
        <Plus size={16} />
        업로드
      </p>
    )}
  </label>
);

export const ImageUploadWithSetting = withSettingItem(ImageUpload);

Select Dropdown 컴포넌트

const SelectDropdown = ({ options, defaultValue, onChange, placeholder }: SelectDropdownProps) => (
  <Select defaultValue={defaultValue} onValueChange={onChange}>
    <SelectTrigger className='w-40'>
      <SelectValue placeholder={placeholder} />
    </SelectTrigger>
    <SelectContent>
      <SelectGroup>
        {options.map(option => (
          <SelectItem key={option.value} value={option.value}>
            <div className='flex items-center'>
              <span>{option.title}</span>
            </div>
          </SelectItem>
        ))}
      </SelectGroup>
    </SelectContent>
  </Select>
);

export const SelectDropdownWithSetting = withSettingItem(SelectDropdown);
export const radiusArr = [0, 0.3, 0.5, 0.75, 1.0].map((value) => ({
  title: String(value),
  value: String(value * 100),
}));

export const opacityArr = Array.from({ length: 11 }, (_, i) => {
  const title = i * 0.1;
  return {
    title: String(title),
    value: String(title * 100),
  };
});

export const modeArr = [
  {
    title: '라이트 모드',
    value: 'light'
  },
  {
    title: '다크 모드',
    value: 'dark'
  },
]

Select 박스를 공통 UI로 사용하기 위해 옵션들도 배열로 분리하여 정리해주었습니다.

최상위 컴포넌트에 적용하기

HOC 패턴으로 정리한 컴포넌트들은 상위 컴포넌트에서 다음과 같이 사용할 수 있습니다.

<ImageUploadWithSetting
  title="로고"
  description="하우스를 대표하는 이미지를 등록하세요."
  id='logo_image'
  src={style.logo_image ? getPublicUrl(`style/${style.logo_image}`) : undefined}
  onChange={handleImageUpload}
/>
...
<SelectDropdownWithSetting
  title="메인 색상"
  description="하우스의 메인 색상을 변경해보세요."
  options={colorArr}
  defaultValue={style.color}
  onChange={(v) => handleUpdateStyle('color', v)}
  placeholder='색상을 선택하세요.'
/>
<SelectDropdownWithSetting
  title="모드"
  description="하우스 모드를 변경해보세요."
  options={modeArr}
  defaultValue={style.mode}
  onChange={(v) => handleUpdateStyle('mode', v)}
  placeholder='모드를 선택하세요.'
/>
...

리팩토링 결과

기존 코드와 비교했을 때 훨씬 깔끔하고 가독성이 좋아졌습니다!

중복된 코드를 공통 UI로 분리해서 코드의 재사용성도 높아졌고, 다양한 설정 항목을 동일한 패턴으로 처리할 수 있게 되었습니다.

0개의 댓글