front-end/react

[React] 컴포넌트 재사용성에 대한 고민(합성 컴포넌트)

hojung 2022. 11. 22.
728x90
반응형

1. 컴포넌트 재사용의 필요성

frontEnd 개발을 하다보면 특히 많이 쓰이는 컴포넌트 들이 존재한다. 

버튼들, 인풋 들, carousel, card 등 ui란 너무 다양하면 사람의 눈에 오히려 피로감을 주고 정돈 되지 않은 느낌을 줄 수 있기에 많은 디자인 패턴에서 공통되는 디자인을 갖고 기능을 갖는 컴포넌트들을 정의한 후 재사용하는 것이 일반적이다. 

 

나는 현재 서비스의 admin페이지를 제작 중이다. 이 페이지 들에는 많은 부분에 select 컴포넌트와 input컴포넌트 들이 존재하는 것을 확인했다. 

반복되는 input, select

위와 같은 컴포넌트들은 비단 저 한 페이지에서만 사용되는 것이 아니었다. 

다양한 페이지에 모두 존재했다. 

다양한 페이지에 인풋과 select 태그 들이 존재한다.
label과 input이 세로 배치

등등 저 많은 컴포넌트 들을 일일히 다 정의하는 것은 비효율적이라고 생각이 되었다. 

 


2. 어떻게...?

내가 처음 선택한 방법은 각 디자인 마다 컴포넌트를 제작하는 것이었다. 

label과 input이 세로 배치

위의 경우는 라벨과 인풋 태그가 세로 배치가 되어 있고

나머지의 경우는 라벨과 인풋 태그가 가로 배치가 되어 있었다. 따라서 나는 

<VerticalInput />,  <CustomInput /> 이라는 두 개의 컴포넌트를 만들어 구분했다. 하지만 또 다른 문제가 생겼다. 

다음과 같이 인풋 태그가 화면의 전체 width를 차지하는 경우가 있었고 절반만 차지하는 경우가 있었다. 

또한 input태그가 아닌 select태그가 들어가는 곳도 존재하였다. 

 

이렇게 계속 뒤늦게 알아차린 상황의 분기 때문에 그 때 그 때마다 작성해두었던 로직을 수정하거나 새로운 컴포넌트를 제작해야하는 일이 발생했다... 심지어 새로운 기능이 추가된 것도 아니라 디자인적 배치 때문에 ...! 

 

생산성은 떨어졌고 코드는 점점 지저분해졌다. 

 

그래서 예전 포스팅에서도 작성했던 합성 컴포넌트에 대한 고민을 하기 시작했다. 

 

https://fe-developers.kakaoent.com/2022/220731-composition-component/

 

합성 컴포넌트로 재사용성 극대화하기

카카오엔터테인먼트 FE 기술블로그

fe-developers.kakaoent.com


3. 전체적인 페이지의 흐름 파악 ( 디자인 시스템 내부에 있는 input, select 파악)

디자인 시스템 내부에 존재하는 인풋 태그들은 다음과 같은 종류가 존재했다. 

1. 화면의 전체를 차지하는 input

2. 화면의 반을 차지하는 input

3. 라벨과 인풋이 세로 배치된 input

4. 화면의 반을 차지하는 Select

 

5. 라벨과 인풋이 세로 배치된 Select

위와 같은 5가지의 경우가 있다는 것을 확인했고 나는 합성 컴포넌트로 구현을 한다면 위의 다섯 가지 경우를 하나의 합성 컴포넌트 안에서 처리하고 싶었다. 

 


4. 구현

합성 컴포넌트란 하나의 Container 컴포넌트 안에 여러가지 컴포넌트들을 조합하여 만들 수 있는 컴포넌트를 뜻한다. 예를 들면 label, input, select를 각각 따로 만들어 둔 후 내가 쓰고 싶을 때 쓰고 싶은 위치에 사용하는 것이다. 

 

그러기 위해서는 정의한 children 컴포넌트들을 모아 줄 Container가 하나 필요하다. 

 

1. Container - component/common/compositionInput/InputMain.tsx

import styled from '@emotion/styled';
import React from 'react';
import { ReactNode } from 'react';

interface Props {
	children?: ReactNode;
	isVisible: boolean;
	isVertical?: boolean;
}

type StyleProps = {
	isVertical?: boolean;
};

const InputMainContainer = styled.div<StyleProps>`
	display: flex;
	flex-direction: ${({ isVertical }) => (isVertical ? 'column' : 'row')};
	width: 100%;
`;

const InputMain = ({ children, isVisible, isVertical }: Props) => {
	if (!isVisible) {
		return null;
	}

	return (
		<>
			<InputMainContainer isVertical={isVertical}>{children}</InputMainContainer>
		</>
	);
};

export default InputMain;

위와 같이 Container에 해당하는 컴포넌트는 전체적인 children의 배치를 담당하고 children을 하나 감싸주는 div태그에 해당한다. 

- isVertical : 세로 배치를 담당하는 Prop

- isVisible : 인풋의 visibility를 담당하는 Prop

 

2. Object.assign -> Assign - Component/Common/compositionInput/index.tsx

컨테이너를 정의해주었으면 그 컨테이너에 들어간 children들을 하나의 객체로써 묶어주는 것이 필요하다. 

그것은 Object객체의 assign함수가 해 줄 것이다. 

import InputLabel from './inputLabel';
import InputLength from './InputLength';
import InputMain from './inputMain';
import InputTitle from './inputTitle';
import MyInput from './MyInput';
import MySelector from './mySelector';

const CompositionInput = Object.assign(InputMain, {
	Input: MyInput,
	Title: InputTitle,
	Label: InputLabel,
	Select: MySelector,
	Length: InputLength,
});

export default CompositionInput;

Object.assign함수는 InputMain을 기준으로 하위 children들을 묶어주는 역할을 한다. 

 

3. Children들 -> Component/Common/compositionInput/inputOOO.tsx

이제 실제 인풋들을 구성하고 있는 children들을 만들어 줄 것이다. 

1. Label

import styled from '@emotion/styled';

type Props = {
	title: string;
};
const MyInputLabel = styled.label`
	font-weight: 400;
	font-size: 12px;
	line-height: 24px;
	white-space: nowrap;
	width: 72px;
`;
const InputLabel = ({ title }: Props) => {
	return <MyInputLabel>{title}</MyInputLabel>;
};

export default InputLabel;

라벨의 경우 그냥 받은 타이틀을 보여주는 역할을 한다. 

 

2. Input

import { ReactNode } from 'react';
import * as S from './styled';
interface Props {
	children?: ReactNode;
	isFull: boolean;
	placeholder?: string;
	value: string | number | string[];
	handleValue?: (e: any) => void;
	isDisabled?: boolean;
}

const MyInput = ({ children, isFull, placeholder, value, handleValue, isDisabled }: Props) => {
	return (
		<>
			<S.MyInput
				isFull={isFull}
				placeholder={placeholder}
				value={value}
				onChange={handleValue}
				disabled={isDisabled && true}
			>
				{children}
			</S.MyInput>
		</>
	);
};

export default MyInput;

인풋의 경우이다. 

- isFull : 아까 화면에 꽉 차는 인풋과 화면의 절반만 차지하는 인풋을 해당 Prop을 이용해 조절한다. 

- value : 인풋의 value

- handleValue : 인풋 태그의 onChange이벤트마다 호출할 콜백함수

- isDisabled : disabled 형태로 만들어주는 상태를 관리하는 prop

 

3. Select

import styled from '@emotion/styled';
import React from 'react';
type Props = {
	backIcon?: any;
	frontIcon?: any;
	options: string[] | number[];
	isFull?: boolean;
	value: string | number;
	handleValue: (e: any) => void;
	isDisabled?: boolean;
	isVertical?: boolean;
};

type StyleProps = {
	isFull?: boolean;
	isVertical?: boolean;
};
const SelectContainer = styled.div<StyleProps>`
	position: relative;
	width: ${({ isFull, isVertical }) => (isVertical ? '100%' : isFull ? '100%' : '36%')};
	display: flex;
	align-items: center;
`;

const MySelect = styled.select<StyleProps>`
	border: 1px solid ${({ theme: { colors } }) => colors.grey[4]};
	border-radius: 10px;
	padding: 0 12px;
	height: 40px;
	width: 100%;
	font-size: 12px;
	z-index: 1000;
	appearance: none;

	color: ${({ theme: { colors } }) => colors.grey[9]};
	&:focus {
		outline: 1px solid ${({ theme: { colors } }) => colors.primary.sky};
	}
	&:disabled {
		color: ${({ theme: { colors } }) => colors.grey[4]};
	}
`;
const SelectIconContainer = styled.span`
	position: absolute;
	right: 10px;
	z-index: 10000;
	pointer-events: none;
`;
const MySelector = ({ backIcon, options, isFull, value, handleValue, isDisabled, isVertical }: Props) => {
	return (
		<>
			<SelectContainer isVertical={isVertical}>
				<MySelect isFull={isFull} value={value} onChange={handleValue} disabled={isDisabled && true}>
					{options.map((item) => (
						<option key={item}>{item}</option>
					))}
				</MySelect>
				{backIcon && <SelectIconContainer>{backIcon}</SelectIconContainer>}
			</SelectContainer>
		</>
	);
};

export default MySelector;

select 태그에는 한 가지 기능이 더 필요했다. 바로 icon을 집어 넣을 수 있는 기능인데 이는 backIcon prop으로 조절 가능하다. 

- backIcon : Select 태그 안 뒷부분에 Icon을 집어넣을 수 있는 prop

- isFull : 화면 전체를 차지 true/false

- value : select 태그 value

- handleValue : select태그 onChange이벤트 마다 호출되는 콜백 함수

- isDisabled : disabled형태로 만들어주는 prop

- isVertical : 세로 배치 

 


5. 실제 컴포넌트 구현

1. 화면의 전체를 차지하는 컴포넌트 

<CompositionInput isVisible={true} isVertical={true}>
		<CompositionInput.Label title="검색조건" />
		<CompositionInput.Input
			isFull={true}
			value={searchQuery}
			handleValue={handleSearchQuery}
			placeholder="검색어를 입력하세요"
		/>
</CompositionInput>

- isFull : true이다.

2. 화면의 반을 차지하는 input

<CompositionInput isVisible={true} isVertical={false}>
		<CompositionInput.Label title={'태그'} />
		<CompositionInput.Input
			isFull={false}
			placeholder="태그를 입력하세요"
			value={tags}
			isDisabled={false}
			handleValue={handleTags}
	/>
		<Spacer />
</CompositionInput>

- isFull : false이다.

라벨과 인풋이 들어가 있다. 

3. 라벨과 인풋이 세로 배치된 input

<CompositionInput isVisible={true} isVertical={true}>
			<CompositionInput.Label title="검색어" />
			<CompositionInput.Input
				value={searchQuery}
				handleValue={handleSearchQuery}
				isFull={true}
				placeholder="검색어를 입력하세요"
			/>
</CompositionInput>

- isVertical : true로 설정해주었다. 

Label과 input태그가 존재한다. 

 

4. 화면의 반을 차지하는 Select

<CompositionInput isVisible={true} isVertical={true}>
		<CompositionInput.Label title={'랜딩 경로*'} />
		<CompositionInput.Select
			isFull={false}
			value={type}
			handleValue={handleType}
			options={bannerLinkTypes}
			backIcon={<SelectArrowIco />}
		/>
</CompositionInput>

- isFull : false이다. 

- isVertical : false이다. 

label과 select가 존재한다. 

5. 라벨과 인풋이 세로 배치된 Select

<CompositionInput isVisible={true} isVertical={true}>
		<CompositionInput.Label title="검색조건" />
		<CompositionInput.Select
			options={searchParam}
			value={email}
			handleValue={handleEmail}
			isFull={false}
			isVertical={true}
			backIcon={<BottomArrowIco />}
		/>
</CompositionInput>

- isFull : false이다.

- isVertical : true이다. 

label과 select가 존재한다. 

 

6. assign의 자유도

다음과 같이 하나의 CompositionInput컴포넌트 안에서는 몇 개의 children을 정의하던 제약이 없다. 

<CompositionInput isVisible={true} isVertical={false}>
	<CompositionInput.Label title={'정렬 순서'} />
	<CompositionInput.Select
		options={numbers}
		isFull={false}
		value={String(exposureSort)}
		handleValue={handleExposureSort}
		isDisabled={!isEditable}
		backIcon={<InputArrowIco />}
	/>
	<Spacer />
	<CompositionInput.Label title={'상태'} />
	<CompositionInput.Select
		options={activeState}
		isFull={false}
		value={String(isExposure)}
		handleValue={handleIsExposure}
		isDisabled={!isEditable}
		backIcon={<InputArrowIco />}
	/>
</CompositionInput>

하나의 CompositionInput컴포넌트 안에 두 쌍의 Select를 정의해주었다. label과 Select의 순서를 변경하는 등의 작업도 가능하고 안의  Select를 다른 div태그로 감싸 레이아웃을 조정하는 것 또한 가능하다. 

 

이로써 위의 다섯가지 경우 혹은 앞으로도 생겨날 더 다양한 디자인적 요소들을 더 쉽고 재사용성 높게 대응할 수 있게 되었다. 

 

앞으로도 항상 머리를 쓰며 프로그래밍하는 사람이 되어야겠다. 

 

728x90
반응형

댓글