[달달쇼핑] 깔쌈한 Form 컴포넌트 만들기 (2)

한낱·2024년 2월 15일
0

달달쇼핑

목록 보기
5/8

라고 적고 트러블 슈팅이라고 읽는다...

(이 글은 지난 글과 이어지는 글입니다.)

지난 글에서 Form 컴포넌트를 만들기 위해 context API를 사용하여 input의 value나 error 상태를 context 내에서 공유하는 방법에 대해 다뤄보았다.

이를 실제로 구현하는 과정에서 react-hook-form이라는 라이브러리를 알게 되었고, 이 라이브러리가 내 생각처럼 일종의 context API를 사용하는 것을 보고 적용해보았다.

요구사항 정리

실제로 form 컴포넌트를 개발할 때 신경써야 했던 요구사항들을 살펴보자.

input 마다 동일한 구성

  • 라벨, input, helper text로 이루어져 있다.
  • input에 focus가 되면 밑줄이 뜬다.
  • 만약 input 내용에 오류가 있다면, 빨간색 밑줄과 빨간색 helper text가 뜬다.
  • 페이지에 존재하는 input 중 한 input이라도 만족하지 못하면 해당 input들을 submit하는 버튼이 disabled 처리된다.
  • 값이 입력되지 않은 상태와 사용자가 입력하다가 값을 모두 지운 상태를 구분하여 후자에만 오류 메시지를 띄워야 한다.

input 마다 다른 점

  • 계좌 번호와 point input은 숫자만 입력할 수 있다. (애초에 숫자만 받아와야 한다.)
  • point input의 경우 3 자리 마다 ','가 찍힌다.
  • point input의 경우 맨 뒤에 'P'이 생긴다.
  • 은행 input의 경우 클릭하면 은행을 선택할 수 있는 drawer가 떠야한다.
  • 각 input 마다 각자의 validation을 가진다.

구현

일단은 input 마다 동일했던 부분은 대부분 ui적인 부분이었고, 이외의 부분도 react-hook-form(context api)를 사용하여 해결할 수 있었다. 따라서 이번 포스팅은 input 마다 다른 점들을 어떻게 구현해내었는지에 집중하여 다뤄보도록 하겠다.

point input 처리

처음에는 동일한 컴포넌트를 사용하여 input 종류가 point인지, account인지에 대한 props만 넘기면 알아서 해당하는 input의 동작이 일어나는 것을 기대하였다.

하지만 input 마다 다른 요구사항을 모두 추상화하기는 무리라는 생각이 들었고, 특히 point input의 경우가 많이 다르기 때문에 해당 컴포넌트와 은행 선택 input은 다른 input과 분리하여 사용하기로 마음먹었다.

그 외 input

그 외 input 먼저 살펴보자.

<input
  className={`... border-b-[3px]
${errors[name] ? 'border-b-Error' : 'border-b-White'}
${isFocused ? '' : 'border-b-transparent'}
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none
`}
  onFocus={handleInputFocus}
  inputMode={isNumber ? 'numeric' : 'text'}
  type={type}
  value={value}
  {...register(name, {
    onBlur: handleInputBlur,
    onChange: handleInputChange,
  })}
  autoFocus={autoFocus}
  disabled={disabled}
  autoComplete="off"
/>

그 외 input으로는 유저 input과 계좌번호 input이 가능하다. 이 부분에서는 다음 기능들과 css를 신경써서 작업했다.

1. 숫자만 입력받는 input

계좌 번호 input은 위 요구사항처럼 애초에 숫자만 입력받을 수 있도록 설정해야하기 때문에, type에 따라 input의 type props와 inputMode props를 제어하도록 설정하였다.

이를 사용하면 숫자만 입력받는 input의 경우 자동으로 숫자 외의 입력은 걸러지고, 모바일의 경우 숫자 키보드가 뜬다.

위 방법만 적용하면 input에 못생긴 spinner가 생긴다. 해당 spinner를 제거하고 싶다면
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none를 추가해야 한다.

2. 자동 완성 끄기

서비스와 상관 없는 이전 입력까지 자동 완성으로 뜨는데, 이를 끄고 싶다면 autoComplete 속성을 off로 설정하면 된다.

point input

point input을 정리된 요구사항대로 3자리 수마다 ','를 붙이거나 맨 끝에 'P'을 붙이는 것은 별로 어렵지 않았다.

1. 3자리 수마다 ',' 붙이기

Intl.NumberFormat이라는 것을 사용하면 언어마다 적합한 숫자 서식을 사용할 수 있다.

경우에 따라 국가별 화폐를 붙이거나 해당 나라 언어로 숫자를 변환하는 등의 방법으로 사용할 수 있다.

export const changeNumberIntoStringWithComma = (point: number) => {
	return new Intl.NumberFormat().format(point);
};

위처럼 작성하면 한국 locale의 경우 3 자리 수마다 ','를 찍을 수 있다.

2. 맨 끝에 'P' 붙이기

p가 붙은 text를 완성해주는 함수를 만들고, value에 씌워주면 된다.

export const getPointText = (point: number) => {
  return `${changeNumberIntoStringWithComma(point)} P`;
}

...

<input
  value={getPointText(value)}
/>

3. 위기 : '지우기'를 시도하면...?

이 중 가장 시간을 소비했던 문제는 '지우기'였다. 유저가 어떤 상황에 따라 지우기를 시도하는지에 따라 input의 값이 엉망진창으로 바뀌었기 때문이다.

결론적으로 point input은 다음과 같이 만들었다. (위 코드와 중복되는 코드는 이해를 위해 제거하였습니다.)

// 관련 함수 코드
export const getOriginalPoint = (pointText: string) => {
	return String(pointText).replace(/[^0-9]/g, '');
};

const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
  setValue(getOriginalPoint(e.target.value));
};

const handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Backspace') {
    // 길이가 1이라면 무조건 '0 P'로 설정한다.
    if (value.length === 1) {
      setValue('0 P');
      return;
    }

    // 그 외에는 맨 뒤 글자를 제거한다.
    setValue(prev => prev.slice(0, prev.length - 1));
  }
};

// input 코드
<input
  onFocus={handleInputFocus}
  onKeyDown={handleInputKeyDown}
  value={getPointText(value)}
  {...register(name, {
    onBlur: handleInputBlur,
    onChange: handleInputChange,
  })}
/>

value state 값에는 숫자만 저장할 수 있게 input의 값이 변할 때마다 getOriginalPoint 함수를 통해 숫자만을 남겨주었다.

실제로 렌더링 될 때에는 'p'가 붙은 value가 렌더링될 수 있게 getPointText 함수로 감싼 값을 넘겨주었다.

그리고, 매 입력이 들어올 때마다 backspace인지를 검사하여 상황에 맞는 지우기를 직접 실행해준다.

validation

validation은 zod 라이브러리를 사용해서 구현해보았다. (zod는 타입스크립트 기반의 라이브러리이기도 하고, schema 구조를 기반으로 validation을 체크한다는 점이 흥미로워 선택하게 되었다.)

schema는 다음과 같은 모양으로 만들었다.

export const accountSchema = z.object({
  USER: z
  .string()
  .min(1, '필수 입력 사항입니다.')
  .max(5, '5 글자 이하로 입력해주세요.'),
  
  ACCOUNT: z
  .string()
  .min(1, '필수 입력 사항입니다.')
  .regex(/^[0-9]+$/, '숫자만 입력해주세요.')
  .max(16, '16 글자 이하로 입력해주세요.'),
});

숫자 외 입력을 잡거나 string 길이 제한을 주는 등의 방법으로 사용할 수 있다.

이렇게 생성한 schema를 react-hook-form으로 전달하면 위에서 작성한 schema에 어긋나는 경우 에러로 잡히게 된다.

const Form = ({ children, onSubmit, schema }: FormProps) => {
	const methods = useForm<FormType>({
		resolver: zodResolver(schema),
		mode: 'onChange',
	});

	const { handleSubmit } = methods;

	return (
		<FormProvider {...methods}>
			<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-1">
				{children}
			</form>
		</FormProvider>
	);
};

값을 강제로 변경시킨 경우 zod validation

point input의 경우 redering할 때 ','를 추가하고 'P'를 추가하였기 때문에, 금액 자체에 대한 validation을 진행하기 힘들었다. 이를 가능하게 하는 방법을 찾던 중 zod 공식 문서에서 해답을 찾았다.

1. transform

transform은 말 그대로 data를 transform하는 데에 사용된다. transform 이후에 chaining으로 연결된 value는 transform의 결과를 사용할 수 있다.

2. refine

refine을 사용하면 custom된 validation을 사용할 수 있다.

위 두 방법을 접목하여 다음과 같이 해결할 수 있었다.

export const pointSchema = z.object({
  POINT: z
  .string()
  .min(1, '필수 입력 사항입니다.')
  .transform(value => +value.replace(/[^0-9]/g, ''))
  .refine(value => value <= 5000, '최대 금액이 5,000 P에요.')
  .refine(value => value >= 1000, '최소 금액이 1,000 P에요.'),
});

아쉬운 점

  1. 은행 선택에 대한 input은 input과 동일한 css를 가지는 button으로 해결했다.

    이로 인해 zod의 validation을 적용할 수 없어서 직접 validation을 구현해야 했다.

  2. point에 대한 값을 미리 수정(','를 추가하거나 'P'를 추가하는 동작)하기 때문에 react-hook-form의 isDirty와 같은 속성을 그대로 사용할 수 없었다.

    다행히도, react-hook-form의 isDirtydirtyFields가 달라서, dirtyFields를 검사하는 방법으로 사용자의 입력이 있었는지 판단할 수 있었다.

위 둘을 원인으로 form 제출하기 버튼에서 disabled인지 판단하기 위한 코드가 아주 더러워졌다.

	const { formState } = useFormContext();
	const { errors, dirtyFields } = formState;
	const { isSelectedBankNeeded, accountInfo } = useAccountInfoStore();

// 각 페이지마다 필요한 input 개수를 props로 전달받고,
// 현재 페이지가 은행 선택이 존재하는 페이지인지에 따라서
// input 개수를 충족하는지를 판단한다.
	const fieldCountForValidation = isSelectedBankNeeded
		? fieldCount - 1
		: fieldCount;

// error가 존재하는지 판단한다.
	const isErrorsEmpty = Object.keys(errors).length === 0;

// dirtyFields의 개수와 input 개수를 비교하여 isDirty 상태를 알아낸다.
	const isDirty =
		isEditing || Object.keys(dirtyFields).length === fieldCountForValidation;

// 은행이 선택되었거나 필요하지 않은 페이지인지 판단한다.
	const isBankSelectedOrNotNeeded =
		(isSelectedBankNeeded && !!accountInfo['BANK']) || !isSelectedBankNeeded;

// 위 상태들을 만족하지 못하는(=disabled가 필요한)지 판단한다.
	const isFormButtonDisabled =
		!isErrorsEmpty || !isDirty || !isBankSelectedOrNotNeeded;

조금은 아쉬움이 남지만, 지난 프로젝트보다는 발전된 form 컴포넌트를 만들어냈다는 사실에 만족하고 있다.

그리고 무엇보다

profile
제일 재밌는 개발 블로그(희망 사항)

0개의 댓글