Headless 컴포넌트로 리팩터링 하기

정호진·2024년 4월 3일
0

React

목록 보기
4/4

이 글 UI로직과 비즈니스 로직을 명확하게 분리할 방법을 찾지 못하고 있던 과거의 제가 이걸 어떻게 하면 잘 분리할 수 있을까? 하는 생각에 작성하는 실험 일지입니다.

React를 통해 기업의 과제 테스트를 보거나 혼자 사이드 프로젝트를 할 때 기능 구현을하면서 항상 고려하는 부분이 UI는 UI만 렌더링 하고 비즈니스 로직은 custom hooks를 통해 분리하는 것 입니다.

하지만 코드를 작성하다 보면 이것은 쉽게 되지 않습니다. 공통적으로 사용되는 부분은 도대체 어디까지 인지, 성급하게 작성했다가 롤백하기는 일쑤고, 이 컴포넌트의 역할이 무엇인지 등등 생각해 볼 것들이 너무 많이 있었습니다. 그래서 일단 돌아가는 쓰레기를 만들자라는 생각에 정말 돌아가는 쓰레기를 만들었습니다.

돌아가는 쓰레기

const QuizForm = ({
	quizState,
	type,
	cancelHandler,
	changeHandler,
	selectHandler,
}: QuizFormProps) => {
	const { data: categories } = useGetCategory();

	const { category, quiz, answer, favorite, explain } = quizState;

	// ... 핸들러 코드들

	return (
		<form className={styles['quiz-form']}>
			<InputLabel title='카테고리' htmlFor='category'>
				<Selector
					type='single'
					list={categories.map((d) => d.name)}
					placeholder='분류를 선택해주세요'
					selected={selected}
					onSubmit={categorySelectHandler}
				/>
			</InputLabel>

			<InputLabel title='문제' htmlFor='quiz'>
				<Input
					id='quiz'
					mode='text'
					name='quiz'
					value={quiz}
					onChange={changeHandler}
					placeholder='문제를 입력해주세요'
					required
				/>
			</InputLabel>

			<InputLabel title='답' htmlFor='answer'>
				<Selector
					type='single'
					placeholder='선택해주세요'
					list={Object.values(QUIZ_ANSWER)}
					onSubmit={answerHandler}
					selected={answer !== undefined ? [QUIZ_ANSWER[answer]] : []}
				/>
			</InputLabel>

			<InputLabel title='해설' htmlFor='explain'>
				<textarea
					id='explain'
					className={styles.explain}
					value={explain}
					onChange={changeHandler}
					name='explain'
					placeholder='해설을 입력해주세요'
					required
				/>
			</InputLabel>

			<InputLabel title='즐겨찾기 등록' htmlFor='favorite'>
				<Selector
					type='single'
					placeholder='선택해주세요'
					list={Object.values(FAVORITE_SELECT)}
					onSubmit={favoriteHandler}
					selected={favorite !== undefined ? [FAVORITE_SELECT[favorite]] : []}
				/>
			</InputLabel>

			<div className={styles['button-container']}>
				<Button type='button' size='small' onClick={cancelHandler}>
					취소하기
				</Button>
				<Button type='submit' size='small' color='primary'>
					{type === 'add' ? '등록하기' : '수정하기'}
				</Button>
			</div>
		</form>
	);
};

export default QuizForm;

DB에 퀴즈의 상태를 추가하거나 수정을 요청하는 역할을 가진QuizForm 컴포넌트 입니다.

카테고리, 문제 등등을 순서대로 입력 받고, 이를 퀴즈값 추가나 퀴즈 수정에 사용할 수 있는 form 컴포넌트 입니다. 하지만 이 컴포넌트는 카테고리 -> 문제 -> 정답 -> 해설 -> 즐겨찾기 -> 제출 의 순서를 가진 퀴즈 폼에만 적용할 수 있습니다. 즉 변경에 유연하지 못합니다

변경에 유연한 컴포넌트

그렇다면 변경에 유연한 컴포넌트는 어떻게 만들어야 할까요? 좀 더 나은 방향으로 개선하기 위해 검색 중에 toss에서 발표한 컴포너넌트 관련 영상을 보고 영감을 받아서 한번 이 방향으로 시도했습니다.

  • 개발자가 생각한 컴포넌트의 역할을 수행할 수 있고
  • 그 컴포넌트들을 구성하는 컴포넌트의 위치를 자유롭게 지정할 수 있으며
  • 컴포넌트의 UI로직과 비즈니스 로직이 분리되어 있는

위 3가지를 기준으로 위에 QuizForm컴포넌트를 리팩터링 해보겠습니다.

QuizForm의 역할

QuizForm의 역할은 말 그대로 퀴즈에 대한 정보를 입력받는 form입니다. 하지만, form의 형태를 굳이 quiz에만 국한할 필요는 없습니다. 지금 QuizForm의 문제는 너무 quiz값을 입력받는데만 치중이 되어 있습니다. 즉, 다른 form역할은 전혀 하지 못하고 있습니다.

이제 여기서 컴포넌트의 역할에 대해 한번 생각해 봐야 합니다. QuizForm을 만드는게 아니라 아예 추상화된 form 컴포넌트를 만든다고 생각하고 컴포넌트를 다시 생각해봐야 합니다.


  <form className={styles['quiz-form']}>
      <InputLabel title='카테고리' htmlFor='category'>
        <Selector
          type='single'
          list={categories.map((d) => d.name)}
          placeholder='분류를 선택해주세요'
          selected={selected}
          onSubmit={categorySelectHandler}
          />
      </InputLabel>
	//... 아래 동일한 순서로 적용
 </form>

form에 특정 컴포넌트 혹은 요소가 존재한다면 추상화된 form 컴포넌트를 만드는데 분명 애로사항이 있습니다. 이 문제를 해결하기 위해서는 결국 QuizForm을 구성하는 컴포넌트의 순서를 외부에서 주입할 수 있도록 변경한다면 좀 더 추상화된 form컴포넌트를 만들 수 있습니다.

이러한 흐름은 자유로운 컴포넌트 배치로 이어집니다.

자유로운 컴포넌트의 배치

위에서도 언급했듯이 QuizForm의 문제는 너무 내부에 의존적이라는 것입니다. 특정 역할만을 수행하는게 아니라 추상적인 컴포넌트를 만들기 위해서는 내부에서 모든 컴포넌트 순서를 결정하지 않고, 외부에서 주입받도록 설계를 하면서 컴포넌트에 어느정도 자유도를 주면서 만들 수 있습니다.

결합도와 응집도

소프트웨어 설계를 할 때 많이 등장하는 용어입니다.

응집도
응집도는 독립적인 단위를 수행하는 모듈이 얼마나 독립적으로 하나의 기능만을 수행하기 위해 있는지를 나타내는 지표입니다.

응집도의 관점에서 QuizForm을 바라본다면, 내부를 구성하는 LabelInput, Select,Button 등의 컴포넌트들이 QuizForm의 역할을 수행하는데 UI적 관점에서 바라본다면 큰(??) 문제가 없습니다. 상태관리 측면에서는 부모에서 props로 handler랑 같이 전부 넘긴다는 점, 그렇다고 서버 상태로 받아오는 categories는 내부에서 관리한다는 점, 핸들러를 외부에서 주입받고도 내부에서 따로 선언한다는 점 등 문제가 몇가지 있긴 합니다.

하지만, 좀 더 추상적으로 바라본 form의 관점에서 바라본다면, UI 컴포넌트의 책임을 좀 더 분리해 볼 수 있습니다. 입력을 받는 컴포넌트와 제출받는 컴포넌트 그리고 입력을 받는 컴포넌트 중에서도 어떤 형태의 컴포넌트를 받아들일지 등등 하나의 역할만을 수행할 수 있도록 코드를 변경해 볼 수 있습니다.

결합도
결합도는 해당 모듈이 다른 모듈들과의 의존성을 나타내는 지표입니다. 결합도가 낮다면 하나의 모듈을 수정해도 그와 의존성이 높은 모듈들이 많이 없기 때문에 유지보수 하기 쉽습니다.

QuizForm의 경우에는 다른 컴포넌트들과 적당한 결합도라고 생각합니다. 당장 Select의 props만 수정해도 Selectform이랑 크게 연관이 있는 컴포넌트가 아님에도 QuizForm 코드를 수정해야 합니다. 결합도를 좀 더 낮추기 위해서는 컴포넌트 내부에서 선언하는 컴포넌트들을 외부에서 주입함으로써 해결할 수 있습니다.

Form 컴포넌트에 공통으로 사용되는 컴포넌트와, 필요한 컴포넌트 기능을 먼저 정의하고, 그 안에 구성되는 내용은 외부에서 주입하는 방식으로 응집도를 높이고 결합도를 낮추는 방식으로 생각해 보겠습니다.

목적에 따른 로직 분리

현재 Form 컴포넌트에는 불필요한(?) 훅이 존재합니다. selector에 option으로 들어갈 카테고리 데이터를 불러오는 훅입니다. selector가 없다면 해당 훅도 있을 필요가 없어집니다. 그리고 외부에서 각각의 handler와 상태를 주입하고 있는 형태로 코드가 작성되어 있습니다. 지금 Form은 비즈니스 로직과 UI 로직이 같이 혼재되어 있는 상태입니다.

제가 원하는 방향은 form에 필요한 로직은 외부에서 주입 받고, 컴포넌트 자체는 UI만을 렌더링 하도록 의도하는 것입니다. 그 과정에서 UI의 렌더링에 필요한 상태나 로직이 있다면 해당 로직을 훅으로 분리하고, 비즈니스 로직에 필요한 로직들은 또 다른 훅으로 분리해서 UI 로직 (컴포넌트 + 훅) + 비즈니스 로직 (submit 했을 때 동작)으로 분리하는게 최적의 목적입니다.

리팩터링

그렇다면 이제 다음과 같이 분리해 보겠습니다.

  • form의 역할을 하면서
  • 외부에서 컴포넌트를 주입함으로써 컴포넌트의 자유도를 높이고
  • 비즈니스 로직은 분리하기

3가지 기준을 갖고 QuizForm 컴포넌트 리팩터링을 시작해보겠습니다. 제일 처음 할 것은 비워내기입니다. form의 역할과 상관 없다고 생각되는 코드들은 모두 쳐낼 생각입니다.

그렇게 된다면 전체적인 wrapper역할을 하는 form태그와 해당 form의 submit을 담당하는 버튼은 필요하게 됩니다. 그것들을 제외하고는 모두 제거하겠습니다.


const QuizForm = ({
	children,
  	type,
}: PropsWithChildren & React.HtmlHTMLAttributes<HTMLFormElement>) => {
	return(
      <form>
      {children}
	  <div>
		<button type='button'>
			취소하기
		</button>
		<button type='submit'>
			제출하기
		</button>
	</div>
  </form>
;
)};

SelectorInputLabel과 같이 작성해 놓은 컴포넌트들은 선언부에서 children으로 주입하도록 하고, 딱 하나의 역할에만 집중할 수 있도록 했습니다. 여기서 약간의 결합도를 추가하겠습니다. 제가 원하는 form의 형태는 제목 - 콘텐츠 와 같이 항상 Title이 존재합니다. 그리고 이 TitleInputLabel 컴포넌트에 children을 할당함으로써 구현이 가능합니다.

하지만, InputLabelQuizForm 컴포넌트와 완전히 독립적이지 않고 약간의 의존성을 넣고싶다면 어떻게 해야할까요? 이때 합성컴포넌트 패턴을 통해 해결할 수 있습니다.

저희는 JSX의 반환 타입이 '객체'라는 것을 기억한다면 이 합성 컴포넌트 패턴을 쉽게 이해할 수 있습니다.

객체에 프로퍼티를 추가하는 방법으로 컴포넌트에 프로퍼티를 추가할 수 있습니다. 컴포넌트의 자유로운 배치를 위해서 Button도 같은 프로퍼티로 할당하겠습니다.


const QuizForm = ({
	children,
}: PropsWithChildren & React.HtmlHTMLAttributes<HTMLFormElement>) => {
	return <form className={styles['quiz-form']}>{children}</form>;
};

type SubmitButtonProps = ButtonProps & {
	type: 'reset' | 'submit';
};

const SubmitButton = (props: SubmitButtonProps) => {
	return <Button {...props} />;
};

export default Object.assign(QuizForm, {
	FormElement: InputLabel,
	SubmitButton,
});

form 컴포넌트 작성을 끝내고 이제 사용한다면 다음과 같이 사용할 수 있습니다.


<QuizForm onSubmit={submitHandler}>
  <QuizForm.FormElement title='카테고리' htmlFor='category'>
    <Selector
      type='single'
      options={CATEGORIES.map((c) => c.name)}
      placeholder='카테고리를 선택해주세요'
      selected={categorySelected}
      changeHandler={categoryChangeHandler}
      isModal={isCategoryModal}
      />
  </QuizForm.FormElement>

//... 아래에 똑같이 작성

  <div className={styles['button-container']}>
    <QuizForm.SubmitButton type='reset'>취소하기</QuizForm.SubmitButton>
    <QuizForm.SubmitButton type='submit' color='primary'>
      제출하기
    </QuizForm.SubmitButton>
  </div>
</QuizForm>

처음 코드와는 유지 보수와 확장성 측면에서 많은 차이가 생겼습니다. selector내부에 category 값을 받기 위해, 내부에서 훅 호출을 분리할 수 있었던 점과 (비즈니스 로직 분리), QuizForm 컴포넌트에서 form 컴포넌트가 되었다는 점 (form역할로 추상화). 마지막으로 자유로운 컴포넌트 배치가 가능해졌다는 점에서 많이 큰 이점을 얻을 수 있었습니다(외부에서 코드 주입).


이처럼 합성 컴포넌트 패턴을 사용한다면 UI로직과 비즈니스 로직 분리가 가능해지고, 이를 통해 유지보수성이 좋은 컴포넌트를 만들 수 있습니다.

어떤 컴포넌트를 만들지는 프로젝트에 따라 달라지겠지만, 컴포넌트별 역할을 명시하고 해당 컴포넌트에서 작동하길 바라는 동작을 정의하게 된다면 어떤식으로 UI로직과 비즈니스 로직 분리가 가능해지고, 이를 통해 변경에 유연한 컴포넌트를 구현할 수 있습니다.

0개의 댓글

관련 채용 정보