최근 팀 프로젝트에서 도서 판매 웹사이트를 만들었다. 내가 작업할 컴포넌트를 메인 페이지, 목록 페이지, 상세 페이지 등 여러 곳에서 같은 UI 요소를 반복해서 사용해야 했다. 이런 상황에서 컴포넌트 재사용성을 높이기 위한 설계 방법을 고민했고, 팀원들이 소위 말하는 '딸깍' 으로 복잡한 과정 없이 UI를 즉각 적용할 수 있게끔 설계하는 것에 집중했다. 오늘은 그 과정에 대한 회고를 하고자 한다.
프로젝트가 커질수록 반복되는 UI 패턴이 많아지는데, 이 때 재사용 가능한 컴포넌트를 잘 설계해 두면
1. 코드 중복 방지 : 같은 기능을 여러 번 구현하지 않아도 됨
2. 일관된 디자인 유지 : 모든 페이지에서 동일한 스타일과 기능을 제공
3. 유지보수가 쉬워짐 : 한 곳만 수정하면 모든 곳에 적용
4. 개발 속도가 빨라짐 : 이미 만들어진 컴포넌트를 활용하면 새 기능 개발이 용이함
와 같은 장점이 생긴다!
프로젝트에서 실제로 적용한 몇 가지 원칙이다.
컴포넌트가 필요한 정보만 받을 수 있도록 props
를 설계하는 것이 중요하다. 필수 props
와 선택적 props
를 구분해서 다양한 상황에 대응할 수 있게 만들었다.
function CardSectionLayout({
title,
path,
children,
className,
}: SectionContainerProps) {
return (
<section className={mergeClasses('max-w-[1000px] mx-auto', className)}>
<h2 className='ml-2 font-semibold text-lg mb-8'>
{path ? (
<Link href={path} className='flex items-center gap-1'>
{title} <ChevronRight className='w-4' />
</Link>
) : (
title
)}
</h2>
{children}
</section>
);
}
이 컴포넌트는 title
과 children
은 필수지만, path
와 className
은 선택적이다. path
가 있으면 제목을 클릭 가능한 링크로 만들고, 없으면 그냥 텍스트로 표시한다.
상황에 따라 다른 UI를 보여주기 위해 조건부 렌더링을 활용했다. 위 코드에서 볼 수 있듯이, path
prop이 있을 때만 제목을 링크로 만들고 화살표 아이콘도 함께 표시한다.
Tailwind CSS를 사용할 때 클래스 충돌 문제를 해결하기 위해 mergeClasses
유틸리티 함수를 만들었다.
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* 주어진 클래스들을 병합하여 반환하는 함수
* `clsx`로 클래스명을 처리하고, `twMerge`로 중복된 Tailwind 클래스를 병합
*
* @param {...ClassValue[]} inputs - 병합할 클래스들
* @returns {string} 병합된 클래스 문자열
*/
export function mergeClasses(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
이 함수는 clsx
와 tailwind-merge
라이브러리를 사용하여 기본 클래스와 사용자 지정 클래스를 충돌 없이 병합해 준다. 기본 스타일은 유지하면서 필요한 부분만 커스터마이징할 수 있게 되었다! 😎
메인 페이지에서는 여러 섹션(베스트셀러, 추천 도서 등)이 있는데, 같은 레이아웃 컴포넌트를 재사용했다.
// 베스트 셀러 섹션
<CardSectionLayout
title='베스트셀러'
path='/book-list/best'
className='w-[85%] mt-16 md:mt-36'
>
<BestSellerCarousel />
</CardSectionLayout>
// 추천 도서 섹션
<CardSectionLayout
title='이 주의 추천 도서'
className='w-[85%] my-16 md:my-36'
>
<RecommendedBooksCarousel />
</CardSectionLayout>
베스트셀러 섹션에는 path
를 제공하여 "더보기" 링크를 활성화했고, 추천 도서 섹션에는 링크가 필요 없어서 path
를 생략했다. 두 섹션 모두 className
을 통해 위치와 여백을 조정했다.
재사용 컴포넌트는 다양한 화면 크기에서도 잘 작동해야 한다. 캐러셀 컴포넌트를 예로 들면,
function BookCarouselLayout(props: { bookList: CardForCarousel[] }) {
const carouselOptions = {
slidesToScroll: SLIDES_TO_ONE_SCROLL.SMALL,
breakpoints: {
'(min-width: 640px)': { slidesToScroll: SLIDES_TO_ONE_SCROLL.MEDIUM },
'(min-width: 768px)': { slidesToScroll: SLIDES_TO_ONE_SCROLL.LARGE },
},
};
return (
<div>
<Carousel opts={carouselOptions}>
<CarouselContent className='-ml-4 md:-ml-6'>
{props.bookList?.map((book) => {
return (
<CarouselItem
key={book.id}
className='basis-1/2 sm:basis-1/3 md:basis-1/4 pl-4 md:pl-6'
>
<BookCard {...book} />
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className='-left-4 md:-left-12 w-7 h-7 md:w-8 md:h-8' />
<CarouselNext className='-right-4 md:-right-12 w-7 h-7 md:w-8 md:h-8' />
</Carousel>
</div>
);
}
화면 크기에 따라 다른 수의 카드를 보여주고, 화살표 버튼의 크기와 위치도 조정했다. 이렇게 하면 모바일부터 데스크탑까지 모든 화면에서 최적의 사용자 경험을 제공할 수 있다 👍
재사용 컴포넌트는 데이터 소스에 독립적이어야 한다. BookCarouselLayout
컴포넌트는 bookList
prop을 통해 데이터를 받기 때문에, 베스트셀러 목록이든 추천 도서 목록이든 관계없이 동일한 UI로 표시할 수 있다.
이렇게 데이터와 UI를 분리하면 컴포넌트 재사용성이 높아지고, 다양한 데이터 소스와 연동하기 쉬워진다.
프로젝트를 진행하면서 재사용 가능한 컴포넌트를 설계하는 것이 얼마나 중요한지 깨달았다. 처음에는 시간이 더 걸리는 것 같지만, 결국 개발 속도를 높이고 일관된 UI를 유지하는 데 큰 도움이 된다는 걸 느꼈다 !
특히 mergeClasses
같은 유틸리티 함수는 작은 코드지만 큰 효과를 발휘했다. 이런 작은 노력들이 모여 유지보수하기 쉬운 코드베이스를 만들어 냈다.
팀원들이 "그냥 이렇게만 쓰면 베스트 셀러 캐러셀이 나온다고요? 엄청 편하다" 라고 해주셨을 때의 뿌듯함을 기억하며 앞으로도 재사용 가능한 컴포넌트를 곰곰히 생각하며 구현해야겠다!