WEGO ) 커스텀 훅을 활용한 인증 폼 구현과 리렌더링 최적화

▪︎ 로그인 폼 구현 과정과 고민들

▫︎ 상태 관리 방식의 선택

로그인 폼을 구현할 때 가장 먼저 고민했던 것은 입력값을 어떤 방식으로 관리할 것인가였습니다. 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
// hooks/useAuthInput.ts
// 상태 관리와 이벤트 핸들링을 위한 커스텀 훅
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
// utils/validateAuthInput.ts
// 유효성 검증 로직
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
// components/auth/form/loginForm.tsx
// UI 컴포넌트
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" }); // 상태 1
const password = useAuthInput({ name: "password" }); // 상태 2

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. 사용자 경험 개선

  • 입력 지연 감소
  • 부드러운 UI 업데이트