카드 페이먼츠 미션에서는 입력에 따른 다양한 상태를 관리해야한다.
카드 만료일 같은 경우는 현재 날짜 23년 4월보다 이전의 날짜를 가진 카드는 등록할 수 없고, 기본적으로 숫자 입력만 받는 등 다양한 제약사항이 존재한다.
이럴 경우 어떻게 재사용성이 높은 Hook을 작성할 수 있을까?
먼저 다양한 상태에 대응하기 위해 도메인을 분석해보자.
예측 못하는 사용자의 다양한 입력에 대응하려면, 입력창은 어떤 상태를 가지는가 파악해야 한다. 우리가 분석한 도메인 지식을 통해 입력창이 가질 수 있는 상태를 고려해보아야 한다. 또한, 사족이 될 수 있으나 상태에 의해 어플리케이션이 어떻게 변할지도 생각해보자.
입력창의 유효성 상태 | 발생해야하는 부수효과 |
---|---|
초기상태(empty) | - |
입력 중(invalid but yet) | 다음 버튼 비활성화, 에러 메세지 표시 |
유효한 입력 완료(valid) | 다음 버튼 활성화 |
유효하지 않는 입력 완료(invalid) | 다음 버튼 비활성화, 에러 메세지 표시 |
입력 중과 유효하지 않는 입력은 같은 상태로 볼 수 있다고 생각한다. 따라서, empty(init)
, invalid
, valid
가 인풋태그가 가질 수 있는 상태라고 정의하고 계속 이어나가보자.
그러면 어떤 부분에서 유효하지 않은지도 생각하고 넘어가자. 일단은 다음과 같은 경우가 유효하지 않는 경우로 판단된다.
사용자 경고 부분은 더 세분화가 될 수 있다. 지금은 포맷이 안맞다는 경고성의 메세지만 출력할 예정이지만, 고도화 한다면 “사용자가 어떤 부분에서 틀렸는지” 까지 알려줄 수 있도록 확장될 수 도 있다. 하지만 미션 기한에 따라 다른 기능도 구현해야하므로 일단은 여기까지만 고민을 해볼 예정이다.
사용자에게 잘못된 입력을 경고해주는 방식은 너무나도 다양하다. 폼 제출 시 사용자에게 어느 부분이 잘못되었는지 경고할 수도 있고, 아님 사용자의 타이핑에 즉각적으로 피드백을 해줄수도 있고, 혹은 사용자가 입력창을 떠날 때(Blur) 경고 해줄 수도 있다.
나는 이번 미션은 입력해야 될 부분이 꽤나 많다고 생각했기 때문에, 즉각적으로 피드백을 주는 방식을 선택하였다. 왜냐하면 많은 입력에 대한 오류를 한꺼번에 보여주면 사용자 입장에서는 많은 오류를 냈다면 앱을 사용하는데 피로할 수 있다고 생각했기 때문이다. 그래서 즉각적인 작은 피드백을 주는 것을 목적으로 설계하였다.
크게 보면 input의 상태는 두 가지로 나뉜다. valid 와 invalid로 나뉘었다. 초기화면에서 사용자에게 무수한 빨간 글씨로 피로감을 주지 않을것이라면 초기상태도 invalid가 아닌 init 같은 상태로 고려해볼만 하다.
처음에는 상태 다이어그램을 그려보았는데, 생각보다 복잡하지 않아 순서도로 그려보았다. 개인적으로 순서도는 유효성을 검증 로직의 흐름을 나타내는데 가장 좋은 기법이 아닐까 생각한다.
매우 간단함은 알 수 있는데… 이걸로 입력창이 가지는 전체상태의 흐름은 파악할 수 없다. 다만, 추후 구현하는데 길잡이 느낌으로는 좋았다.
지금까지 설계한(설계라고 할것도 없지만) 것을 기반으로 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;
formatDispatcher
는 input
별로 구현해 string
이 들어오면 input
에 대한 상태를 알려주는 함수이다. 여기서 타입스크립트의 유용함이 빛을 발하는 것 같다. 지금와서 보면 네이밍이 매우 개떡같지만, 타입만을 통해 인자로 받은 콜백이 어떤 일을 하는지 어림잡아 유추할 수 있다.
이 훅은 어떤 책임을 가질까?
이 정도만 한다면 재사용 가능하다 생각한다. 상태에 따라 에러메세지나 폼의 제출가능 여부는 다른 컴포넌트에서 할 일이라는 생각이 드므로, 이 정도만 해도 좋다는 생각이 든다.
컴포넌트는 이제 할일이 많다. 또한 input도 많다. 디자인 상 input은 비슷한 형식을 가진다.
각각의 입력구획을 보면 유사한 부분이 보인다. 3부분으로 나눌 수 있겠다.
이런식으로 각각의 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을 받은 뒤, 에러메세지를 띄워주는 컴포넌트이다.
아래는 ErrorMessage
(Footer) 이다. 유효한지 안한지에 따른 상태와 inputType
으로 에러 메세지를 띄워주는 책임을 가진다. getErrorMessage
는 inputType
과 status
에 따라 미리 설정된 에러 메세지를 반환하는 함수이다.
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에서 어떻게 입력들을 관리하면 좋을지 살펴보았다. 다음과 같은 부분이 핵심이라고 생각한다.
사용자의 입력(e.target.value
)을 강제하려면, hook에서 callback을 받아 제한할 수 있다. 마찬가지로, 구체화된 컴포넌트는 별다른 책임이 없다. html 태그를 사용해 강제하려면 수정이 필요하겠지만, 로직적인 수정은 아까 작성한 useInput
훅에서 onChange
로직을 변경해주면 된다고 생각한다.
부족한 지식으로 덤벼본 것이니, 다른 생각이나 잘못되었다는 부분은 주저하지 말고 댓글을 달아주기 바란다.