현재 프로젝트에서 로그인과 회원가입기능을 만드는 중이다.
이번 프로젝트에서는 Supabase를 사용해서 로컬로그인과 카카오 로그인을 모두 구현하고자한다.
반복을 줄이고 원하는 기능을 모두 구현하기 위해 다음과 같은 요구사항을 정의하였다.
기능 요구 사항
@
를 포함한 이메일 형식을 만족해야 한다.컴포넌트 설계를 위해 공통컴포넌트는 어떤 조건을 만족해봐야 하는지 알아보았다.
그 결과 두가지의 중요한 요소를 선정하였다.
공통 컴포넌트는
1. 특정도메인 맥락으로 부터 분리되어야 한다.
2. 디자인 변경사항에 유연하게 대응하여야 한다.
컴포넌트가 특정 도메인과 결합되어있다면 다른 도메인에서 재사용하기 어렵고, 비즈니스 요구사항이 변경되었을 때, 다른 도메인 로직에 영향을 줄 수 있다.
예를들어 사용자들의 주소 목록을 랜더링하는 컴포넌트가 있을 경우, 이를 '주소목록'의 역할대신 '목록'으로 보다 범용성있게 만들 수 있다.
현재 프로젝트에 적용시켜보자면, '최소8자리~ 최대 12자리로 작성해야 하며, 영소문자와 숫자를 필수적으로 포함' 되어야하는 input 요소가 있다면,아이디, 닉네임을 입력할 때 똑같은 디자인을 가지고 있다고 하더라도 재사용할 수 없다.
그렇다면 '비밀번호는 최소8자리~ 최대 12자리로 작성해야 하며, 영소문자와 숫자를 필수적으로 포함'이라는 로직을 객체로 분리하고 이를 input컴포넌트의 되부에서 주입시켜준다면 어떨까?
기존 코드
type SignInForm = {
email: string;
password: string;
};
export default function SignIn({
setView,
}: {
setView: React.Dispatch<React.SetStateAction<AuthView>>;
}) {
const onSubmit: SubmitHandler<SignInForm> = data => {
console.log('제출됨');
};
const {
register,
handleSubmit,
formState: {errors},
} = useForm();
return (
<div >
<h2 > 로그인 </h2>
<form
className=
onSubmit={handleSubmit(onSubmit)}
>
<input
{...register('email', {
required: '이메일을 입력해주세요',
pattern: {
value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
message: '올바른 이메일 형식이 아닙니다.',
},
})}
placeholder="이메일을 입력해 주세요."
/>
{errors.email && typeof errors.email.message === 'string' && (
<span>
<em>{errors.email.message}</em>
</span>
)}
<input
{...register('password', {
required: '비밀번호를 입력해 주세요',
minLength: {
value: 8,
message: '비밀번호는 최소 8글자 이상이여야 합니다.',
},
maxLength: {
value: 12,
message: '비밀번호는 12글자를 초과할 수 없습니다.',
},
pattern: {
value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
message: '영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요',
},
})}
placeholder="비밀번호를 입력해 주세요."
/>
{errors.password && typeof errors.password.message === 'string' && (
<span>
<em>{errors.password.message}</em>
</span>
)}
<Button type="submit" label="로그인 하기" />
</form>
<div >
<span >아직 계정이 없습니까?</span>
<button onClick={() => setView('SIGN_UP')}>
회원가입 하기
</button>
</div>
<Seperator text="or" width={52} />
<KakaoLogin />
</div>
);
}
현재 SignIn컴포넌트의 UI안에는 이메일과 비밀번호의 유효성확인을 위한 로직이 함께 존재한다.
변경가능성이 높은 도메인 맥락인 유효성 확인 로직을 객체로 분리해주자.
// 1. 도메인 맥락 : 유효성 확인 로직 분리
const SignInValidation = {
email: {
required: '이메일을 입력해주세요',
pattern: {
value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
message: '올바른 이메일 형식이 아닙니다.',
},
},
password: {
required: '비밀번호를 입력해 주세요',
minLength: {
value: 8,
message: '비밀번호는 최소 8글자 이상이여야 합니다.',
},
maxLength: {
value: 12,
message: '비밀번호는 12글자를 초과할 수 없습니다.',
},
pattern: {
value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
message: '영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요',
},
},
};
라이브러리 인터페이스에 변경 가능성이 있으므로 어댑터를 사용하여 연결해주자.
// 2. 라이브러리 인터페이스 변경을 고려해 adaptor를 통해 연결
type FormAdaptorArgs = {
register: UseFormRegister<FieldValue<FieldValues>>;
validator: {[key: string]: RegisterOptions};
name: keyof SignInFormValues;
};
...
const formAdaptor = ({register, validator, name}: FormAdaptorArgs) => {
return register(name, validator[name]);
};
...
<input
{...formAdaptor({register, validator: SignInValidation, name: 'email'})}
placeholder="이메일을 입력해 주세요."/>
{errors.email && typeof errors.email.message === 'string' && (
<span>
<em>{errors.email.message}</em>
</span>
)}
위 코드에서 input
창과 에러를 표시하는 부분을 묶어서 Input 컴포넌트로 만들어 보겠다.
TextInput
에서는 HTML attributes를 하나하나 받아와서 뿌려줬었는데, 요구사항에 따라 속성값들이 달라지면 컴포넌트 뿐 아니라 사용처에서도 모두 수정해야 하는 것이 불편했다. 따라서 props라는 객체 형태로 받아와서 적용하는 방식으로 수정하였다.register
함수 역시 InputHTMLAttributes
에 해당하는 프로퍼티만 리턴하기 때문에 함께 사용해도 문제될 게 없다.<Input
props={{
...formAdaptor({
register,
validator: SignInValidation,
name: 'email',
}),
placeholder: '이메일을 입력해주세요.',
type: 'email',
}}
errors={errors.email}
label="이메일"
/>
type InputProps = {
props: InputHTMLAttributes<HTMLInputElement>;
errors?: FieldError;
label?: string;
};
export default function Input({props, errors, label}: InputProps) {
return (
<div >
{label && <label>{label}</label>}
<input {...props} />
{errors && typeof errors.message === 'string' && (
<span>
<em>{errors.message}</em>
</span>
)}
</div>
);
}
현재 로그인 폼과 회원가입 폼에서는 공통적인 로직이 존재한다.
따라서 공통 레이아웃을 가지는 Form 컴포넌트를 만들고,
Input 컴포넌트는 react-hook-form과는 완전히 분리시킬것이다.
Form 컴포넌트는 그러니까,
SignIn
export default function SignIn({
setView,
}: {
setView: React.Dispatch<React.SetStateAction<AuthView>>;
}) {
const onSubmit = data => {
...
};
const SigninInputs = {
...
};
return (
<div className="flex flex-col gap-3 justify-center items-center">
<Form headerText="로그인" buttonText="로그인하기" onSubmit={onSubmit} inputs={SigninInputs}>
<div>
<KakaoLogin />
</div>
</Form>
</div>
);
}
Form 컴포넌트
export default function Form({children, onSubmit, inputs, buttonText, headerText}: FormProps) {
const {
register,
handleSubmit,
formState: {errors},
} = useForm();
const formAdaptor = ({register, name, input}: FormAdaptorArgs) => {
const {attributes, validate} = input;
return {...register(name, validate), ...attributes};
};
return (
<div className="flex flex-col gap-3 justify-center items-center">
<h2 className="font-semibold text-lg">{headerText} </h2>
<form
className="flex flex-col gap-3 justify-center items-center"
onSubmit={handleSubmit(onSubmit)}
>
{Object.entries(inputs).map(([name, input]: [string, InputType]) => (
<Input
key={name}
errorMessage={errors[name]?.message?.toString() || ''}
props={formAdaptor({register, name, input})}
label={name}
/>
))}
<Button type="submit" label={buttonText} />
</form>
{children}
</div>
);
}
이렇게 복잡하던 SignIn 컴포넌트를 SignIn/Form/Input컴포넌트로 분리해보면서 각자 로그인처리를 위한 로직, react-hook-form 라이브러리 의존성, input창 UI관리에 대한 책임을 나눌 수 있었다.
그동안 개발 시간에 쫓겨 구현자체에만 몰두하다 보면 컴포넌트를 분리하지 않고 하나의 컴포넌트에 많은 책임을 몰아넣거나, 혹은 나만 알아볼수 있는 스파게티 코드를 짜기도 한다. 그럴 때마다 항상 나뿐 아니라 남들도 잘 알아볼수 있는 코드, 재사용성 있는 코드를 작성하려 노력한다.
공부하다보니 추상화의 단계에 따라 어떤 차이점이 있는지, 어느정도까지 추상화해야하는지에 대한 궁금점이 들었다.다음에는 확장성있는 UI를 작성하는 방법, 추상화란 무엇인지에 대해 더 알아보고 싶다...!
마지막으로 이번 기회를 통해 도메인 로직이 무엇인지, 왜 분리해야 하는 것이지에 대해 배우고, 적용해보았다. 자료를 찾아보면 원래의 목적에서 벗어나거나 꼬리 질문들이 생기기 마련인데, 오늘은 '폼 리팩토링'이라는 큰 목적 아래서 학습해서 삼천포로 빠지지 않았던 것 같다. 한가지 구현 목표와 요구사항을 정하고 필요한 자료를 학습하며 리팩토링하는 이번 방식이 꽤 효율적이란 생각이 들었다. 다음에도 이런 방식을 적용해봐야겠다..!