칭찬 서비스 Complimate 개발일지(3): Multi-step Form 리팩토링

김 주현·2023년 11월 3일

Complimate 개발일지

목록 보기
3/5

이번엔 리팩토링을 진행해보자! 리팩토링 대상은 다음과 같다.

  • Radix UI의 Form Control는 asChild를 써서 아래로 위임해주기
    • Form Control 그대로 쓰니까 스타일링과 기능 구분이 안 되는 것 같다.
  • useMultistepForm 구조 및 변수 이름 변경
    • 변수 이름이 의미적으로 와닿지 않는 것들이 많아서 변경해야겠다.
    • Form Component 구조 변경으로 인한 수정
  • Form Component들의 이벤트 처리 방식이 지금 중복이 많다. 상위로 보내서 하나로 관리해주기
    애니메이션은 Motion Component으로 빼주기
    • 바로 붙여버리니까 코드가 상당히.... (생략)
  • Style 관리하기
  • Type 관리하기
  • Sub Component 관리하기

Radix UI Form Control

as-is

<Form.Control
  id="email"
  type="email"
  value={email}
  onChange={handleChange}
  required
/>```

현재는 Form.Control 자체에 값을 넣어주어서 처리하고 있다. 이게 나쁘다는 건 아니지만, 현재 상황에서는 두 가지의 이유에서 바꿀 이유가 있다고 판단했다.

기능과 스타일링의 분리

  • Form.Control를 쓰는 이유는, 더 나아가서 Radix UI를 쓰는 이유는 기본적인 기능이 구현된 Unstyled Library이기 때문이다. 개발자는 UI에만 집중할 수 있도록 도와주는 라이브러리이기 때문에 분리를 해주어야 한다고 생각했다.

Input 객체의 변경 가능성

  • 지금은 단순히 하나의 input Element지만, 나중에 Custom Input을 만들 것을 생각하면 어차피 분리를 시켜주어야 했다.

to-be

<Form.Control asChild>
  <input
    type="password"
    id="password"
    value={password}
    onChange={onChange}
    required
  />
</Form.Control>```

이러한 이유로 Radix UI에서는 asChild라는 Prop이 존재한다. 이 속성을 쓰게되면 이벤트들이 아래 자식에게 위임된다.

useMultistepForm 구조 및 변수 이름

as-is

import { useState, ReactElement } from "react";

type StepProp = {
  title: string;
  element: ReactElement;
};

const useMultiStepForm = (steps: StepProp[]) => {
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const prev = () => {
    setCurrentStepIndex((index) => (index <= 0 ? 0 : index - 1));
  };

  const next = () => {
    setCurrentStepIndex((index) =>
      index >= steps.length - 1 ? index : index + 1,
    );
  };
?
  return {
    currentStepIndex,
    currentTitle: steps[currentStepIndex].title,
    currentStep: steps[currentStepIndex].element,
    isFirstStep: currentStepIndex === 0,
    isLastStep: currentStepIndex === steps.length - 1,
    prev,
    next,
  };
};

export default useMultiStepForm;```

현재 useMultiStepForm Hook이다. 지금도 괜찮은 것 같지만~ 좀 더 구체화하고 추가하고 싶었다.

불분명하고 적절하지 않은 이름

  • Step과 Form의 이름을 혼용해서 사용하고 있는 것 같다는 생각이 들었다. Step은 단계를 말하고 Form은 객체를 말하는 거니까 정확하게 써야할 것 같다.

Hook에서 더 관리할 수 있는 상태들

  • 만들고 보니 컴포넌트가 아닌 Hook 자체에서 관리할 수 있는 상태들이 보였다. 물론 그렇게 되면 일반적인 multi-step의 형태에서 벗어나긴 하지만, 지금의 프로젝트에선 깔끔할 것.

to-be

import { ComponentType, useState } from "react";

export type StepProp = {
  title: string;
  Component: ComponentType\<any>;
};

export type MultiStepFormType = {
  currentStepIndex: number;
  currentTitle: string;
  isFirstStep: boolean;
  isLastStep: boolean;
  progressDirection: number;
  CurrentForm: ComponentType\<any>;
  prevStep: () => void;
  nextStep: () => void;
  formData: object;
  updateFields: (field: object) => void;
};

const useMultiStepForm = (
  steps: StepProp[],
  initialFormData: object,
): MultiStepFormType => {
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const [formData, setFormData] = useState(initialFormData);
  const [progressDirection, setProgressDirection] = useState(1);

  const updateFields = (field: object) => {
    setFormData((prev) => ({ ...prev, ...field }));
  };

  const prevStep = () => {
    setCurrentStepIndex((index) => (index <= 0 ? 0 : index - 1));
    setProgressDirection(-1);
  };

  const nextStep = () => {
    setCurrentStepIndex((index) =>
      index >= steps.length - 1 ? index : index + 1,
    );
    setProgressDirection(1);
  };

  return {
    currentStepIndex,
    currentTitle: steps[currentStepIndex].title,
    CurrentForm: steps[currentStepIndex].Component,
    isFirstStep: currentStepIndex === 0,
    isLastStep: currentStepIndex === steps.length - 1,
    progressDirection,
    prevStep,
    nextStep,

    formData,
    updateFields,
  };
};

export default useMultiStepForm;```

하나하나 보자.

StepProp의 프로퍼티 키 변경

export type StepProp = {
  title: string;
  Component: ComponentType\<any>;
};```

요녀석은 element에서 Component로 변경되었다. 처음 의도는 <Component />를 넣는 녀석이라 element라는 이름이 맞았지만, 지금은 Component 자체를 넣는 녀석이 되어서 그에 맞게 변경하였다. any를 쓰고 싶진 않았지만,,, 그렇게 됐다(ㅋㅋ)

추가된 상태

  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const [formData, setFormData] = useState(initialFormData);
  const [progressDirection, setProgressDirection] = useState(1);```

원래는 currentStepIndex라는 녀석만 있었는데, formData와 progressDirection이 추가되었다. formData까지는 충분히 넣을 수 있는 녀석인데, progressDirection은 분명히 이 프로젝트에서만 필요한 존재일 것.

아무튼 생각해보니, formData역시 Hook에서 관리를 충분히 해줄 수 있을 것 같아서 추가해주었다.

추가된 상태에 따른 코드

const updateFields = (field: object) => {
  setFormData((prev) => ({ ...prev, ...field }));
};

const prevForm = () => {
  // ...
  setProgressDirection(-1);
};

const nextForm = () => {
  // ...
  setProgressDirection(1);
};

추가된 상태들에 대한 코드들. formData를 위해서 field를 받아 업데이트해주는 녀석과, 이전과 다음 버튼에 따라 진행방향을 바꿔주는 코드를 추가했다.

formData나 field type은 Record 타입으로 선언해줄 수 있을 것 같은데, 일단 내버려뒀다(...)

prev, next가 아닌 prevStep, nextStep

const prevStep = () => { };
const nextStep = () => { };

그리고 prev와 next의 이름도 명확하게 바꿨다. 대상이 확실해짐!

변경된 useMultiStepForm에 의한 이벤트 코드 구조 변경

한두 개가 바뀐 게 아니라 거의 구조가 다 바뀐 느낌인데, 가장 포인트가 되는 부분은 입력을 받는 부분이 되겠다.

as-is

  const updateFields = (fields: Partial\<FormDataProps>) => {
    setFormData((prev) => ({ ...prev, ...fields }));
  };

  const { currentTitle, currentStep, prev, next, isFirstStep, isLastStep } =
    useMultiStepForm([
      {
        title: "Input Email",
        element: <EmailForm {...formData} updateFields={updateFields} />,
      },
      {
        title: "Input Password",
        element: <PasswordForm {...formData} updateFields={updateFields} />,
      },
      {
        title: "Input Nickname",
        element: <NicknameForm {...formData} updateFields={updateFields} />,
      },
    ]);```

처음엔 이렇게 element 자체를 넣어서 줬는데 이로 인해서 생기는 단점이 있었다.

코드의 중복 및 유지보수의 불편함

  • 일단 같은 코드가 들어간다는 점에서 감점인데, 만약 Form들마다 필요한 공통 Prop이 생겨버린다면 저걸 일일이 다.... 추가해줘야 할 것이다. 생각만해도 끔찍해!

to-be

  const multiStepList: StepProp[] = [
    {
      title: "Input Email",
      Component: EmailForm,
    },
    {
      title: "Input Password",
      Component: PasswordForm,
    },
    {
      title: "Input Nickname",
      Component: NicknameForm,
    },
  ];

  const initialData: FormDataProps = {
    email: "",
    password: "",
    passwordConfirm: "",
    nickname: "",
  };

  const { CurrentForm } = useMultiStepForm(multiStepList, initialData);

  const handleChange = (event: ChangeEvent\<HTMLInputElement>) => {
    updateFields({ [event.target.id]: event.target.value });
  };

  () => <CurrentForm {...formData} onChange={handleChange} />``

지금 관련된 코드들이 다 흩어져있어서 그렇지만 그 코드들만 모아보면 대충 이렇게 된다. Component 자체를 받으니 나중에 Prop을 넘겨주는 것도 한 줄의 코드로도 할 수 있게 되었다.

여담으로 처음에는 Javascript는 무슨 변수에 함수가 들어갈 수 있냐고 되게 투덜댄 기억이 있는데,,, 이젠 이 느슨함 못 잃어...(ㅋㅋ)

Style / Type / SubComponent 관리

Style / Type

이 부분에 대해서는 나도 계속해서 찾아보고 배워가는 중이라 어떤 게 효율적이고 어떤 게 메리트가 있는 건지 잘 모르겠지만, 최근 내가 마음에 들어하는 방법으로 처리해봤다.

  1. 먼저 파일로 분리한다.
    • 파일이름은 각각 [NAME].styled.ts, [NAME].types.ts가 된다.
  2. 각 변수마다 export 해준다.
    - 예를 들어 다음과 같은 식이다.
    export const Page = styled("div", { });
    
    export const Container = styled("div", { });```
  3. 불러오는 쪽에선 별칭으로 쓴다.
    - import * as S from './[NAME].styled.tx 로 불러와서 다음과 같이 쓰는 것.
    <S.Page>
      <S.Container>
      	...
      </S.Container>
    </S.Page>```

요새는 이렇게 쓰는 게 굉장히 명확하다는 생각이 들었다. Style Component인지, 아니면 React Component인지 구분이 딱 되니까! 아참, 그리고 참고로 Type은 별칭을 안 쓰는 편이 나은 것 같기도.. 굳이 다른 것과 구분해줄 필요가 없으니 말이다. (아직은 필요성을 못 느꼈다)

여튼 이런 식으로 다 구분해주었다!

SubComponent

여러 페이지에서 필요한 컴포넌트가 아니라 한 페이지 안에서 구조상 나눠주어야 할 경우가 있는데, 이럴 때도 위와 같이 관리해주기로 했다.

파일은 [NAME].component.tsx 로 만들고, 별칭을 붙여 관리해주었다.

as-is

코드가 너무 더러워서,,, 패스한다.

to-be

<S.Page>
  <S.Container>
    <S.Header>
      <button/>
      <C.AnimateTitle />
      <button />
    </S.Header>

    <S.Body>
      <C.AnimateForm />
    </S.Body>
  </S.Container>
</S.Page>```

Prop은 다 떼고 구조만 가져왔다. 훠~얼씬 간결해지고 보기가 좋아졌다. 이거지...


후기

전체적으로 어렵지 않은 리팩토링이었지만 나르음 고민이 많았던 리팩토링. 리팩토링의 제 맛은 역시 Before과 After를 비교하는 재미랄까.... 궁금한 분들은 아래의 링크에서 비교해보시길(ㅋㅋ)

profile
FE개발자 가보자고🥳

0개의 댓글