컴포넌트 구현을 안 해본 FE 개발자는 없을 것입니다.
개발자마다 각자의 기준에 의해 컴포넌트 단위를 결정하고 컴포넌트의 형태, 기능을 정의합니다.
컴포넌트 구현 방법은 명확한 정답은 없다고 생각합니다.
- 컴포넌트마다도 형태, 기능이 다르기도 하고,
- 어느 곳에서 사용하는 지,
- 개발 기한이 얼마나 주어졌는지에 따라 컴포넌트 설계에 대한 고민의 시간이 달라지기 때문입니다.
다만, 컴포넌트를 잘 구현해놓으면 개발 생산성이 높아지기 때문에 많은 고민이 필요한 것은 사실인 것 같습니다.
저는 주로 신규 프로젝트에 많이 참여했었고, 프로젝트들 별로 디자인이 달랐습니다.
그래서 아토믹 디자인 패턴을 사용하지 않았고, 회사 내에 디자인 시스템도 만들 필요가 없었는데요.
이런 환경의 기준 (소규모 프로젝트) 으로 컴포넌트를 다음과 같이 정리했습니다.
컴포넌트를 만들 수 있는 경우 4가지
1. 여러 페이지에서 사용이 되는가? (공통 컴포넌트 or 디자인 시스템 컴포넌트 일 가능성 ⬆️)
2. 반복적으로 사용되는 UI를 가지고 있는가?
3. 페이지에서 의미론적으로 구분해야 하는가?
4. 뷰 로직이 많아져서 정리할 필요가 있는가?
* 여러 페이지에서 사용이 되는가?
페이지에서 전역적으로 사용할 수 있는 컴포넌트를 의미합니다.
버튼, 팝업, 모달, 네비게이션 바 등이 있으며, 공통 컴포넌트 또는 디자인 시스템의 컴포넌트로 정의할 수 있습니다.
이런 컴포넌트는 여러 곳에서 사용할 수 있다는 점에서 재사용적인 의미가 강하게 드러납니다.
* 반복적으로 사용되는 UI를 가지고 있는가?
반복적으로 UI를 그려야 할 때가 있습니다.
댓글 리스트의 댓글, 이벤트 배너 슬라이드의 배너, 어떤 리스트의 특정 항목 하나와 같이
반복적으로 표현할 수 있는 UI + 데이터의 조합을 컴포넌트로 구분할 수 있습니다.
* 페이지에서 의미론적으로 구분할 수 있는가, 뷰 로직을 정리할 필요가 있는가?
이 두 가지 경우로 컴포넌트를 만드는 경우는 같은 이유로 설명할 수 있을 것 같습니다.
바로 '가독성'인데요.
가령, 위 페이지의 '글 작성하기', '오늘의 사진' 부분을 구현한다고 했을 때,
<PageContainer css={containerCss}>
<Navigation>
<Navigation.Back />
<Navigation.Title>쪽지 작성</Navigation.Title>
</Navigation>
{/* 글 작성하기, 오늘의 사진 */}
<section>
<div>
<h3>글 작성하기</h3>
<div>
<textarea placeholder="예)" value={text} onChange={handleChange} />
</div>
<div>
<span>{text.length}/500</span>
</div>
</div>
<div>
<h3>오늘의 사진</h3>
<PhotoUpload>
<label htmlFor="fileUpload">
<Button variant="dashed">
<ButtonContents>
<Icon name="Camera" width={24} height={24} />
<div>사진 추가하기</div>
</ButtonContents>
</Button>
</label>
<input
id="fileUpload"
type="file"
accept="image/*"
multiple
max={3}
onChange={handleChange}
onClick={(e: any) => (e.target.value = null)}
/>
</PhotoUpload>
</div>
</section>
...
</PageContainer>
이렇게 구현하면 뷰 로직이 많아져서 한 눈에 보기 어려운 경우가 있습니다.
<PageContainer css={containerCss}>
<Navigation>
<Navigation.Back />
<Navigation.Title>쪽지 작성</Navigation.Title>
</Navigation>
{/* 글 작성하기, 오늘의 사진 */}
<ContentBody>
<TextSection>
<Description>글 작성하기</Description>
<TextContent />
</TextSection>
<PhotoSection>
<Description>오늘의 사진</Description>
<PhotoContent />
</PhotoSection>
</ContentBody>
...
</PageContainer>
컴포넌트로 구분하고, 네이밍을 잘 활용하면 가독성을 높일 수 있습니다.
좋은 네이밍은 의미론적으로 화면의 영역을 구분할 수 있도록 하여, 추후 변경사항이 발생할 때도 빠르게 원하는 영역을 찾을 수 있도록 도와줍니다.
컴포넌트의 구성
컴포넌트는 UI (or 형태) 와 기능으로 구성되어 있습니다.
기능은 상태, 액션 등을 의미합니다.
대부분의 컴포넌트들이 이 두 가지를 포함하고 있습니다.
그런데,
어떤 컴포넌트는 기능은 동일하지만 UI가 달라질 필요가 있고,
어떤 컴포넌트는 기능과 UI가 경우에 따라 달라질 필요가 있습니다.
어떤 경우가 있는지 살펴보겠습니다.
기능은 동일하지만 UI가 달라질 필요가 있다?
공통 컴포넌트로 사용할 수 있는 버튼이 좋은 예시가 될 수 있습니다.
버튼을 다음과 같이 정의해보겠습니다.
UI : 기본적인 UI를 가지고 있고,
UI : 내부에 텍스트 또는 다른 UI를 보여줄 수 있으며,
기능 : 클릭하면 특정 함수를 실행할 수 있습니다.
interface ButtonProps extends PropsWithChildren {
disabled?: boolean;
variant: 'primary' | 'dashed';
onClick?: () => void;
}
export default function Button({
variant,
disabled,
children,
onClick,
}: ButtonProps) {
return (
<StyledButton variant={variant} disabled={disabled} onClick={onClick}>
{children}
</StyledButton>
);
}
const StyledButton = styled.button<{
variant: ButtonProps['variant'];
disabled: ButtonProps['disabled'];
}>`
border-radius: 8px;
width: 100%;
height: ${layouts.button};
${(props) =>
props.variant === 'primary' &&
`
background-color: #000;
color: #fff;
font-weight: 700;
`}
${(props) =>
props.variant === 'dashed' &&
`
border: dashed 1px #e4e4e4;
background-color: #fff;
color: #555;
font-weight: 600;
`}
${(props) => props.disabled && `background-color: #ddd;`}
`;
몇 가지 테마를 정의해서 개발자가 UI를 변경할 수 있도록 했습니다.
버튼 컴포넌트와 스타일 로직이 결합되어 있는 경우입니다.
버튼 내부에 텍스트 외에 다른 UI 또는 컴포넌트를 전달할 수 있는 확장성(children
)을 제공합니다.
커스텀하게 스타일을 수정할 수 있도록 style
Prop을 제공해도 좋을 것 같네요.
또 다른 예시로 아이콘을 들 수 있습니다.
아이콘은
크기, 색깔, 클릭할 수 있다는 공통적인 특징이 있으며,
아이콘 별로 UI와 이름이 달라질 수 있습니다.
import * as Icons from '@/public/emotionIcons';
type EmotionIconProps = {
name: keyof typeof Icons;
width: number | string;
height: number | string;
fill: string | 'none';
stroke?: string;
onClick?: () => void;
};
export default function EmotionIcon(props: EmotionIconProps) {
const Icon = Icons[props.name];
return <Icon {...props} />;
}
// @/public/emotionIcons.tsx
import Default from './default.svg';
import Depressed from './depressed.svg';
import Flutter from './flutter.svg';
import Glad from './glad.svg';
import Touched from './touched.svg';
export { Default, Depressed, Flutter, Glad, Touched };
기능과 UI가 경우에 따라 달라지는 경우 ?
대표적인 예로 팝업창을 들 수 있습니다.
팝업 내부에 텍스트를 보여줄 지, 이미지를 보여줄 지, 버튼을 하나만 보여줄 지, 버튼을 두 개 보여줄 지 등...
요구사항이 변동될 수 있는 경우가 많고, 그에 따라 기능과 UI가 달라질 수 있습니다.
다른 예시로 네비게이션 바를 들 수도 있습니다.
위의 디자인을 보고 네비게이션 바를 설계한다고 했을 때,
UI적으로는 1) 뒤로가기 아이콘, 2) 타이틀, 3) 완료 텍스트 가 필요해보입니다.
기능적으로는 1) 뒤로가기 아이콘 클릭, 2) 완료 텍스트 클릭 이 필요해보입니다.
type NavigationProps = {
isBack?: boolean;
isComplete?: boolean;
title?: string;
onBack?: () => void;
onComplete?: () => void;
};
export default function Navigation({
isBack,
isComplete,
title,
onBack,
onComplete,
}: NavigationProps) {
const router = useRouter();
return (
<StyledNavigation>
<div>
{isBack && (
<Icon
name="ArrowRight"
width={20}
height={20}
onClick={onBack || router.back}
/>
)}
</div>
<div>{title}</div>
<div>{isComplete && <div onClick={onComplete}>완료</div>}</div>
</StyledNavigation>
);
}
초기에 정의한 내용을 가지고 위와 같이 구현해보았습니다.
뒤로가기 버튼, 완료 버튼은 선택적(optional)으로 렌더링하도록 했습니다.
그런데,,, 만약에 요구사항이 추가/변경되면 어떻게 될까요?
- 뒤로가기 버튼 자리에 다른 아이콘을 넣는다던가?
- 완료 버튼 자리에 X버튼을 넣는다던가?
- 타이틀 자리에 이미지를 넣는다던가?
한번, 이 요구사항들을 적용해볼까요?
type NavigationProps = {
isBack?: boolean;
isComplete?: boolean;
isClose?: boolean;
title?: children;
onBack?: () => void;
onClickIcon?: () => void;
onComplete?: () => void;
onClose?: () => void;
};
export default function Navigation({
isBack,
isComplete,
title,
onBack,
onClickIcon,
onComplete,
onClose
}: NavigationProps) {
const router = useRouter();
return (
<StyledNavigation>
<div>
{isBack && (
<Icon
name="ArrowRight"
width={20}
height={20}
onClick={onBack || router.back}
/>
)}
{isIcon && (
<Icon
name="Something"
width={20}
height={20}
onClick={onClickIcon}
)/>
</div>
<div>{children}</div>
<div>{isComplete && <div onClick={onComplete}>완료</div>}</div>
<div>{isClose && (
<Icon
name="Close"
width={20}
height={20}
onClose={onClose}
)}</div>
</StyledNavigation>
);
}
이런 식으로 구현해볼 수 있겠지만... 하나의 컴포넌트의 기능들만 늘어가고 Props는 너무 많아지고 있습니다.
이 네비게이션 바를 여러 페이지에서 사용한다고 가정해봅시다.
요구사항이 변경됨에 따라, 새로운 기능을 추가하거나 기존의 기능을 수정한다고 할 때...
네비게이션 바를 사용하는 곳의 코드도 전부 수정해야 할 수도 있습니다.
네비게이션 바와 특정 기능들이 아주 강하게 결합되어 있는 경우입니다.
적절한 추상화 작업을 통해서 이런 결합을 느슨하게 처리해줄 수 있습니다.
추상화 1 : Render Props를 이용해보자
네비게이션 바를 살펴보면, 디자인 상으로 3개의 영역으로 구분할 수 있습니다.
이 3개의 영역이란 개념을 사용하여, 각각의 영역에 컴포넌트를 주입(DI)시켜주면 어떨까요?
type NavigationProps = {
left?: React.ReactNode;
middle?: React.ReactNode;
right?: React.ReactNode;
};
export default function Navigation({
left,
middle,
right,
}: NavigationProps) {
return (
<StyledNavigation>
{left && left}
{middle && middle}
{right && right}
</StyledNavigation>
);
}
// 네비게이션 바를 사용하는 특정 페이지
...
const Left = <Icon name="Back width={20} height={20} onClick={handleBack}
const Middle = <div>타이틀</div>
const Right = <div onClick={handleComplete}>완료</div>
<Navigation
left={<Left />}
middle={<Middle />}
right={<Right />}
/>
이렇게 컴포넌트를 설계하면, 컴포넌트를 사용하는 곳에서 UI + 기능을 정의할 수 있어서 자유도가 높아집니다.
어떤 새로운 요구사항이 들어와도, 세 가지 영역에 적절하게 컴포넌트를 주입해주면 되겠네요 !
조금 아쉬운 점은 세 가지 영역으로 제한을 했다는 것과 매번 컴포넌트를 주입해줘야 한다는 부분이 있습니다.
추상화 2 : Compound 패턴을 이용해보자
Compound 패턴은 여러 컴포넌트를 조합해서 하나의 컴포넌트를 구현하는 패턴입니다.
네비게이션 바를 뜯어보면 뒤로가기, 타이틀, 완료와 같이 몇 가지 부품으로 더 쪼갤 수 있는데,
컴포넌트를 사용하는 곳에서 사용하고 싶은 부품만 조립해서 사용하면 되는 것입니다.
Render Props 패턴을 사용하면 어떤 컴포넌트던지 주입할 수 있다는 장점이 있지만,
사용하고 싶은 컴포넌트를 매번 만들어서 주입해줘야 하는 단점이 있습니다.
Compound 패턴은 몇 가지 부품들을 만들어놓기 때문에 이런 불편함이 다소 줄어듭니다.
type NavigationProps = {
children: React.ReactNode;
};
type BackProps = {
onClick?: () => void;
};
type TitleProps = {
children: React.ReactNode;
};
type CompleteProps = {
onClick: () => void;
};
function Navigation({ children }: NavigationProps) {
return <StyledNavigation>{children}</StyledNavigation>;
}
function Back({ onClick }: BackProps) {
const router = useRouter();
return (
<StyledBack>
<Icon
name="ArrowRight"
width={20}
height={20}
onClick={onClick || router.back}
/>
</StyledBack>
);
}
function Title({ children }: TitleProps) {
return <StyledTitle>{children}</StyledTitle>;
}
function Complete({ onClick }: CompleteProps) {
return <StyledComplete onClick={onClick}>완료</StyledComplete>;
}
export default Object.assign(Navigation, {
Back,
Title,
Complete,
});
// 네비게이션 바 사용
<PageContainer css={containerCss}>
<Navigation>
<Navigation.Back />
<Navigation.Title>작성한 쪽지 확인</Navigation.Title>
<div>쪽지는 내일까지!</div>
</Navigation>
...
</PageContainer>
위처럼 조립하고 싶은 부품만 꺼내서 사용하면 됩니다. (Navigation.Back
, Navigation.Title
)
또, 별도의 UI + 기능을 넣고 싶다면 Navigation
하위에 넣어주면 되는 것입니다.
무조건 추상화가 좋은 것일까요 ?
여태까지 자이언트 컴포넌트(Props가 많아지는 경우, 위의 예시로 네비게이션 바)를 뜯어고치는 추상화 작업을 거쳤습니다.
요구사항 수정이 많이 발생할 수 있는 컴포넌트에 추상화를 적용하여 효율적으로 사용함을 확인했습니다.
그렇다면, 수정이 많이 발생할 것 같은 컴포넌트에 추상화를 적용해야만 할까요?
GNB 컴포넌트는
- 스타일이 변경될 소지도 있고,
- 아이콘 위치, 순서라던가,
- 아이콘 갯수도 변경될 수 있어 보이네요.
function Gnb() {
const router = useRouter();
const goPage = (pathname: '/home' | '/noteWrite?page=1' | '/myPage') => {
router.push(pathname);
};
return (
<StyledGnb>
<div>
<button onClick={() => goPage('/home')}>
<i className="fa-solid fa-house fa-lg" aria-label="홈" />
</button>
</div>
<div>
<button onClick={() => goPage('/noteWrite?page=1')}>
<i className="fa-solid fa-plus fa-lg" aria-label="쪽지작성" />
</button>
</div>
<div>
<button onClick={() => goPage('/myPage')}>
<i className="fa-solid fa-user fa-lg" aria-label="마이페이지" />
</button>
</div>
</StyledGnb>
);
}
export default memo(Gnb);
const StyledGnb = styled.nav`
background-color: #ddd;
position: fixed;
left: 50%;
bottom: 0;
transform: translateX(-50%);
text-align: center;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
width: 100%;
max-width: ${layouts.deviceWidth};
height: ${layouts.gnb};
div > button {
width: 50%;
height: ${layouts.gnb};
}
`;
코드를 살펴보면, GNB 스타일 로직과 특정 페이지 이동 기능이 GNB 버튼들과 결합이 되어 있습니다.
이런 경우라면 추후 변경사항이 일어날 때, 많은 코드를 수정하는 등의 비용이 발생할까요?
이 경우에는 그렇지 않습니다.
GNB 컴포넌트는 최상위 레이아웃 컴포넌트와 결합이 되어 있고, 이 곳 한 군데에서만 사용하고 있습니다.
변경이 발생하더라도 GNB 컴포넌트 하나만 수정하면 되기 때문에, 굳이 GNB 컴포넌트를 추상화하거나 쪼개는 작업을 할 이유가 없어 보입니다.
결론
- 컴포넌트를 만들 수 있는 경우의 수
- 컴포넌트의 구성
- 컴포넌트를 언제 추상화할 수 있는지
등을 알아봤습니다.
컴포넌트를 구현하기에 앞서, 다음과 같은 고민이 우선되어야 할 것으로 보입니다.
- 컴포넌트의 변경사항은 얼마나 발생할 지?
- 컴포넌트를 어디에서 사용하는 지?
- 컴포넌트를 얼마나 사용하는 지?
등의 몇 가지 기준을 통해, 추상화 정도를 결정하는 것이 좋은 컴포넌트 구현의 밑거름이 되지 않을까 싶습니다.
참고
카카오엔터 FE 기술블로그 : 합성 컴포넌트로 재사용성 극대화하기
FECONF 2022 [B1] 디자인 시스템, 형태를 넘어서
'React' 카테고리의 다른 글
[React] 리액트 컴포넌트 최적화해보기 (with. useCallback, React.memo) (0) | 2023.06.22 |
---|---|
[React] React-Query 기본 사용법 정리 (useQuery, useMutation) (0) | 2023.01.20 |
[React] React-Query 상태(Status) & StaleTime, CacheTime 정리 (0) | 2023.01.18 |
[React] React-Query를 이용해서 페이지네이션 구현하기 (0) | 2022.11.27 |
[React] React + Swiper.js를 이용해서 카드 UI를 인라인 형식으로 보여주기 (0) | 2022.07.29 |