나만의 Form Input 관리법 - (with Custom Hook)

2yunseong·2023년 4월 27일
0

개요

카드 페이먼츠 미션에서는 입력에 따른 다양한 상태를 관리해야한다.

카드 만료일 같은 경우는 현재 날짜 23년 4월보다 이전의 날짜를 가진 카드는 등록할 수 없고, 기본적으로 숫자 입력만 받는 등 다양한 제약사항이 존재한다.

이럴 경우 어떻게 재사용성이 높은 Hook을 작성할 수 있을까?

먼저 다양한 상태에 대응하기 위해 도메인을 분석해보자.

설계

입력창이 가진 모든 상태를 고려

예측 못하는 사용자의 다양한 입력에 대응하려면, 입력창은 어떤 상태를 가지는가 파악해야 한다. 우리가 분석한 도메인 지식을 통해 입력창이 가질 수 있는 상태를 고려해보아야 한다. 또한, 사족이 될 수 있으나 상태에 의해 어플리케이션이 어떻게 변할지도 생각해보자.

입력창의 유효성 상태발생해야하는 부수효과
초기상태(empty)-
입력 중(invalid but yet)다음 버튼 비활성화, 에러 메세지 표시
유효한 입력 완료(valid)다음 버튼 활성화
유효하지 않는 입력 완료(invalid)다음 버튼 비활성화, 에러 메세지 표시

입력 중과 유효하지 않는 입력은 같은 상태로 볼 수 있다고 생각한다. 따라서, empty(init), invalid, valid가 인풋태그가 가질 수 있는 상태라고 정의하고 계속 이어나가보자.

유효하지 않는 경우 고려

그러면 어떤 부분에서 유효하지 않은지도 생각하고 넘어가자. 일단은 다음과 같은 경우가 유효하지 않는 경우로 판단된다.

  • 아직 모두 입력되지 않은 상태
  • 포맷에 적합하지 않은 입력일 때

사용자 경고 부분은 더 세분화가 될 수 있다. 지금은 포맷이 안맞다는 경고성의 메세지만 출력할 예정이지만, 고도화 한다면 “사용자가 어떤 부분에서 틀렸는지” 까지 알려줄 수 있도록 확장될 수 도 있다. 하지만 미션 기한에 따라 다른 기능도 구현해야하므로 일단은 여기까지만 고민을 해볼 예정이다.

사용자에게 경고해주는 방식 선택

사용자에게 잘못된 입력을 경고해주는 방식은 너무나도 다양하다. 폼 제출 시 사용자에게 어느 부분이 잘못되었는지 경고할 수도 있고, 아님 사용자의 타이핑에 즉각적으로 피드백을 해줄수도 있고, 혹은 사용자가 입력창을 떠날 때(Blur) 경고 해줄 수도 있다.

나는 이번 미션은 입력해야 될 부분이 꽤나 많다고 생각했기 때문에, 즉각적으로 피드백을 주는 방식을 선택하였다. 왜냐하면 많은 입력에 대한 오류를 한꺼번에 보여주면 사용자 입장에서는 많은 오류를 냈다면 앱을 사용하는데 피로할 수 있다고 생각했기 때문이다. 그래서 즉각적인 작은 피드백을 주는 것을 목적으로 설계하였다.

플로우 차트

크게 보면 input의 상태는 두 가지로 나뉜다. valid 와 invalid로 나뉘었다. 초기화면에서 사용자에게 무수한 빨간 글씨로 피로감을 주지 않을것이라면 초기상태도 invalid가 아닌 init 같은 상태로 고려해볼만 하다.

처음에는 상태 다이어그램을 그려보았는데, 생각보다 복잡하지 않아 순서도로 그려보았다. 개인적으로 순서도는 유효성을 검증 로직의 흐름을 나타내는데 가장 좋은 기법이 아닐까 생각한다.

매우 간단함은 알 수 있는데… 이걸로 입력창이 가지는 전체상태의 흐름은 파악할 수 없다. 다만, 추후 구현하는데 길잡이 느낌으로는 좋았다.

구현

hook 작성

지금까지 설계한(설계라고 할것도 없지만) 것을 기반으로 Custom Hook을 만들어보자.(useInputs라고 한 것은 기존에 프로젝트에서 useInput을 사용하고 있어 임시로 s를 붙인거니 네이밍에 대해서는 이해해주기 바란다.)

내가 도출한 요구사항은 “사용자 입력에 대해 즉각적인 피드백” 을 주어야하므로, 리액트 앱이 Form의 상태를 관리하여야 한다. 따라서 제어 컴포넌트로 설계하였으며, 이를 위해 Input Hook에서는 Input을 저장할 state가 필요하다.

그 다음으로는 유효한지 나타내는 상태이다. 지금은 유효/유효하지 않음/초기상태 만 있지만, 확장을 통해 다양한 상태를 처리하려면 문자열 코드로 하는게 나아보였다

import { useState } from 'react';

type InputStatus = 'INIT' | 'VALID' | 'INVALID';

const useInputs = (init = '') => {
  const [status, setStatus] = useState<InputStatus>('INIT');
  const [value, setValue] = useState<string>('');

};

export default useInputs;

설계한 다이어그램에 따라 값이 변경될 때 현재 값이 유효한지 유효하지 않는지 판단해야한다. 이는 onChange에서 구현해보자.

change 이벤트가 발생할 때 마다 값이 유효한지 검사하고, 상태를 저장해주어야 한다.

하지만 모든 input마다 유효한 기준이 다르기 때문에, 기준을 처리해주기 위해 Custom Hook을 사용할 때 callback을 받아오자.

import { useState } from 'react';

type INPUT_STATUS = 'INIT' | 'VALID' | 'INVALID';

const useInputs = (formatDispatcher: (str: string) => INPUT_STATUS, init = '') => {
  const [status, setStatus] = useState<INPUT_STATUS>('INIT');
  const [value, setValue] = useState<string>('');

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const inputValue = e.target.value;
    setStatus(formatDispatcher(inputValue));
    setValue(inputValue);
  };

  return { value, status, onChange };
};

export default useInputs;

formatDispatcherinput 별로 구현해 string이 들어오면 input에 대한 상태를 알려주는 함수이다. 여기서 타입스크립트의 유용함이 빛을 발하는 것 같다. 지금와서 보면 네이밍이 매우 개떡같지만, 타입만을 통해 인자로 받은 콜백이 어떤 일을 하는지 어림잡아 유추할 수 있다.

다음 단계에 앞서

이 훅은 어떤 책임을 가질까?

  • 값 제어/저장
  • 값에 따른 상태 평가/저장

이 정도만 한다면 재사용 가능하다 생각한다. 상태에 따라 에러메세지나 폼의 제출가능 여부는 다른 컴포넌트에서 할 일이라는 생각이 드므로, 이 정도만 해도 좋다는 생각이 든다.

이제, 컴포넌트로

컴포넌트는 이제 할일이 많다. 또한 input도 많다. 디자인 상 input은 비슷한 형식을 가진다.

각각의 입력구획을 보면 유사한 부분이 보인다. 3부분으로 나눌 수 있겠다.

  1. Header
    • 카드번호, 만료일, 등이 적혀져 있는 Label역할을 하는 Header이다.
  2. Body
    • 실제로 입력창(회색부분)과 함께 놓여있는 입력창의 주된 부분이다. 때로는 입력창 이외의 다른 것들(Tool Tip)이 오기도 할 것이다.
  3. Footer
    • 디자인에는 나와있지 않지만, 입력창 밑에 오류 메세지를 보여줄 예정이다.

이런식으로 각각의 Input은 공통적인 특성을 공유한다. 이 때 리액트 공식문서(레거시) - 합성 vs 상속 을 참고해볼만 하다고 생각했다.

재사용 가능한 컴포넌트 설계

내 생각에 재사용 가능한 컴포넌트를 설계하려면 가장 중요한 것은 공통된 특징을 상위로 끌어오는 것이다. 마치 객체지향의 추상화나 상속과도 같은 것이라고 생각이 든다.

const InputContainer = ({
  children,
  className,
  status,
  inputType,
}: PropsWithChildren<InputContainerProps>) => {
  return (
    <section className={className}>
      {children}
      <ErrorMessage status={status} inputType={inputType} />
    </section>
  );
};

물론 위 컴포넌트의 구현을 뚝딱한 것은 아니다. 처음에는 기본 틀만 잡고, 필요한 부분이 생길 때 마다 Props로 받아와 전달해주었다.

설계상 Header 와 Body 부분을 분리해야되었지만 일단은 필요성을 못느껴 함께 children으로 받아오도록 구현하였다.

컴포넌트가 어떤 목적으로 설계되어있는지, 관심사는 무엇인지 살펴보자.

이 컴포넌트는 ErrorMessage가 에러를 처리하는 방식과, input이 어떻게 입력을 받는지 알 필요가 없다. 단지 children을 받은 뒤, 에러메세지를 띄워주는 컴포넌트이다.

Footer(ErrorMessage)

아래는 ErrorMessage(Footer) 이다. 유효한지 안한지에 따른 상태와 inputType으로 에러 메세지를 띄워주는 책임을 가진다. getErrorMessageinputTypestatus에 따라 미리 설정된 에러 메세지를 반환하는 함수이다.

const ErrorMessage = ({ inputType, status }: ErrorMessageProps) => {
  return <div className="error-message">{getErrorMessage(inputType, status)}</div>;
};

재사용 가능한 컴포넌트 구체화 하기

아래 컴포넌트는 오로지 ExpireDateInput을 어떻게 받을지에 대해서만 집중한다. ExpireDate에 대한 입력값이 유효한지, 유효한 상태에 따라 에러 메세지를 띄우거나 폼의 입력을 제한하는건 이 컴포넌트의 관심사가 아니다.

const AddCardExpireDateInput = ({ expireMonth, expireYear }: AddCardExpireDateInputProps) => {
  const status = calcMultipleStatus([expireMonth.status, expireYear.status]);
  return (
    <InputContainer className="card-expired-input-container" status={status} inputType="expired">
      <span className="form-label">만료일</span>
      <div className="card-expired-input">
        <input
          className="card-expired"
          value={expireMonth.value}
          onChange={expireMonth.onChange}
          name="month"
          type="number"
          required
        />
        <span>/</span>
        <input
          className="card-expired"
          value={expireYear.value}
          onChange={expireYear.onChange}
          name="year"
          type="number"
          required
        />
      </div>
    </InputContainer>
  );
};

마무리

지금까지 공부한 내용을 바탕으로 입력이 다양한 Form에서 어떻게 입력들을 관리하면 좋을지 살펴보았다. 다음과 같은 부분이 핵심이라고 생각한다.

  1. 도메인을 파악해 로직의 흐름을 다듬어 놓고 진행하자.
  2. 공통된 로직은 분리해 사용 시에는 구체화해서 사용한다.
  3. 컴포넌트도 마찬가지로 공통된 특성은 공통 컴포넌트로 분리하려 노력하자.
  4. 컴포넌트의 목적(책임)은 뚜렷해야 하고, 가급적 하나의 목적만 달성할 수 있도록 하자.

생각해보기

Q. 좀 더 복잡한 유효성을 처리하려면 어떻게 해야하는가?

  • 복잡한 유효성으로 에러메세지를 다양하게 하려면, 컴포넌트는 그대로 두고 컴포넌트에서 사용하는 getErrorMessage, 인자로 전달하는 formatDispatcher만 변경하면 된다. 컴포넌트는 UI에 집중한다.

Q. 사용자에게 유효성을 강제하려면 어떻게 해야 하는가?

사용자의 입력(e.target.value)을 강제하려면, hook에서 callback을 받아 제한할 수 있다. 마찬가지로, 구체화된 컴포넌트는 별다른 책임이 없다. html 태그를 사용해 강제하려면 수정이 필요하겠지만, 로직적인 수정은 아까 작성한 useInput 훅에서 onChange로직을 변경해주면 된다고 생각한다.

부족한 지식으로 덤벼본 것이니, 다른 생각이나 잘못되었다는 부분은 주저하지 말고 댓글을 달아주기 바란다.

profile
개발 발자국 남기기

0개의 댓글