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>
);
커스텀 훅을 사용해서 비즈니스 로직과 뷰 로직을 분리했지만 아직 좋지 않은 냄새가 나는 것 같습니다...😢 냄새의 원인을 살펴보면 아래와 같은 문제점이 있습니다.
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
를 전달받아, 이 컴포넌트에 title
과 description
을 포함한 레이아웃을 제공하는 역할을 하고, 실제 콘텐츠는 전달받은 컴포넌트가 처리하도록 할 것입니다.
❓ 전달받는 컴포넌트의 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로 분리해서 코드의 재사용성도 높아졌고, 다양한 설정 항목을 동일한 패턴으로 처리할 수 있게 되었습니다.