
▪︎ 로그인 폼 구현 과정과 고민들
▫︎ 상태 관리 방식의 선택
로그인 폼을 구현할 때 가장 먼저 고민했던 것은 입력값을 어떤 방식으로 관리할 것인가였습니다. React에서는 크게 두 가지 접근 방식이 있습니다:
제어 컴포넌트 (Controlled Component)
- 리액트가 폼 데이터를 완전히 제어
- useState를 사용하여 상태 관리
- 실시간으로 입력값 검증 가능
비제어 컴포넌트 (Uncontrolled Component)
- DOM이 폼 데이터를 처리
- useRef를 사용하여 DOM에 직접 접근
- 필요할 때만 값을 참조
저는 다음과 같은 3가지 이유로 useState를 선택하여 구현하였습니다.
1. 상태의 본질
1 2 3 4 5 6
| const LoginForm = () => { const email = useAuthInput({ name: 'email' }); const password = useAuthInput({ name: 'password' }); }; };
|
- 입력값은 시간에 따라 변화하는 애플리케이션의 상태
- UI에 즉시 반영되어야 하는 데이터
- 상태 변화에 따른 부수 효과(유효성 검증) 필요
2. 실시간 유효성 검증
1 2 3 4 5 6 7
| const validate = ({ name, value, password }: ValidateOptions): boolean => { switch (name) { case "email": return REGEX.email.test(value); } };
|
- 사용자 경험 향상을 위한 즉각적인 피드백 필요
- 상태 변화를 감지하여 자동으로 검증 수행
3. 제어 컴포넌트의 이점
- 폼 데이터에 대한 완벽한 제어 가능
- 입력값 포맷팅이나 제한 용이
- 여러 입력 필드 간의 연동 용이
▫︎ 로직을 분리하여 기능 구현
코드의 재사용성과 유지보수성을 높이기 위해 다음과 같이 구조를 분리했습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
const useAuthInput = ({ name, password }: Props) => { const [value, setValue] = useState(""); const [isValid, setIsValid] = useState<boolean | null>(null);
const debouncedValidate = useMemo( () => debounce((newValue: string) => { if (!newValue) { setIsValid(null); return; }
const validationResult = validate({ name, value: newValue, password });
setIsValid(validationResult); }, 250), [name, password] );
const handleChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = e.target.value;
setValue(newValue); debouncedValidate(newValue); }, [debouncedValidate] );
useEffect(() => { return () => { debouncedValidate.cancel(); }; }, [debouncedValidate]);
return { value, isValid, setValue, setIsValid, handleChange, }; };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
const validate = ({ name, value, password }: ValidateOptions): boolean => { switch (name) { case "email": return REGEX.email.test(value); case "password": return REGEX.password.test(value); case "passwordConfirm": return password ? value === password : false; case "currentPassword": return REGEX.currentPassword.test(value); case "newPassword": return REGEX.newPassword.test(value); case "name": return REGEX.name.test(value); case "nickname": return REGEX.nickname.test(value); case "contact": return REGEX.contact.test(value); case "birthDate": return REGEX.birthDate.test(value); case "verifyNumber": return REGEX.verifyNumber.test(value); default: return false; } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
const LoginForm = () => { const email = useAuthInput({ name: "email" }); const password = useAuthInput({ name: "password" }); const { mutate: login, isPending: isLoggingIn } = useLogin();
const handleLogin = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault();
login({ email: email.value, password: password.value, }); };
return ( <form className="mb-4 w-full" onSubmit={handleLogin} data-testid="login-form"> <AuthText type="email" name="email" size="full" value={email.value} isValid={email.isValid} onChange={email.handleChange} />
<AuthPassword name="password" value={password.value} isValid={password.isValid} onChange={password.handleChange} /> <Button label="로그인" type="submit" size="full" className="mt-[180px] hover:bg-primary-normal" disabled={!email.isValid || !password.isValid} showSpinner={isLoggingIn} /> </form> ); };
|
이러한 구조는 각 부분의 책임이 명확하다는 점, 테스트가 용이하다는 점, 코드의 재사용성이 향상된다는 장점이 있습니다.
▪︎ 상태 공존과 리렌더링 최적화
▫︎ 상태 공존 (State Colocation)
상태 공존이란 하나의 컴포넌트 내에서 여러 개의 독립적인 상태가 함께 존재하는 상황을 말합니다.
1 2 3 4 5 6 7 8 9 10 11
| const LoginForm = () => { const email = useAuthInput({ name: "email" }); const password = useAuthInput({ name: "password" });
return ( <form> <AuthText {...email} /> // 이메일 입력 시 패스워드도 리렌더링 <AuthPassword {...password} /> // 패스워드 입력 시 이메일도 리렌더링 </form> ); };
|
상태 공존으로 인해 한 입력 필드의 상태 변화가 다른 필드의 불필요한 리렌더링을 유발한다는 문제점이 있었습니다. 이것은 성능 저하로 이어지고 결국 사용자 경험을 저하시키는 결과를 낳을 수 있었습니다.
이를 위해 메모이제이션 기능을 이용한 최적화 전략을 사용하기로 했습니다.
▫︎ memoization을 통한 최적화
React.memo를 통한 컴포넌트 메모이제이션
1 2 3 4 5 6 7 8 9 10 11
| const AuthText = memo( ({ type, name, value, isValid, }: Props) => { } );
|
- props가 변경되지 않은 컴포넌트의 리렌더링 방지
- 각 입력 필드가 독립적으로 렌더링
useCallback을 통한 이벤트 핸들러 메모이제이션
1 2 3 4 5 6 7 8
| const handleChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = e.target.value; setValue(newValue); debouncedValidate(newValue); }, [debouncedValidate] );
|
- 매 렌더링마다 새로운 함수가 생성되는 것을 방지
- 의존성 배열에 debouncedValidate만 포함하여 불필요한 재생성 방지
useMemo를 통한 검증 함수 메모이제이션
1 2 3 4 5 6 7 8 9 10 11 12
| const debouncedValidate = useMemo( () => debounce((newValue: string) => { if (!newValue) { setIsValid(null); return; } const validationResult = validate({ name, value: newValue, password }); setIsValid(validationResult); }, 250), [name, password] );
|
- 고비용 연산인 debounce 함수의 재생성 방지
- 의존성이 변경될 때만 새로운 함수 생성
▪︎ 결과
이러한 최적화 전략들을 적용한 결과 다음과 같은 점들이 개선되었습니다.
1. 성능 향상
1 2 3
| [리렌더링 패턴] 최적화 전: Input1 변경 → 모든 Input 리렌더링 최적화 후: Input1 변경 → Input1만 리렌더링
|
2. 메모리 사용 개선
- 불필요한 함수 생성 감소
- 효율적인 메모리 관리
3. 사용자 경험 개선