이번 포스팅은 어느 회사에서 과제테스트든 실무에서든 회원가입폼을 검색없이 멋지게 만들어보라고 했을 때 '내가 과연 어디까지 리팩토링 해가며 멋지게 만들어볼 수 있을까?' 하는 생각이 들어, 마침 비즈니스 로직 분리 (커스텀 훅 패턴)에 대해서 공부도 할 겸 미래의 입사테스트도 공부할 겸 겸사겸사 커스텀 훅을 활용한 회원가입폼과 투두리스트를 만들어서 포스팅을 해볼 생각이다. 이런걸 왜하냐 생각할 수도있지만 기본코드부터 시작해서 커스텀 훅 패턴을 검색없이 손에 익히기 위해서는 간단한것부터 적용해보는 것도 좋을 것 같다고 생각했다. 제 코드가 완벽하게 만들어진 것이 아닐테니 보완부분은 댓글로 알려주셔도 감사합니다 :)
커스텀 훅?
리액트 공식문서 상에서 커스텀 훅의 핵심 키워드는 반복되는 로직이다. 리액트 공식 문서를 보면 개발을 하다 보면 가끔 상태 관련 로직을 컴포넌트 간에 재사용하고 싶은 경우가 생깁니다. 이 문제를 해결하기 위한 전통적인 방법이 두 가지 있었는데, higher-order components와 render props가 바로 그것입니다. Custom Hook은 이들 둘과는 달리 컴포넌트 트리에 새 컴포넌트를 추가하지 않고도 이것을 가능하게 해줍니다. 라고 나와있는데, 그냥 쉽게 말해서 같은 커스텀훅을 여러 컴포넌트에서 사용해도 각 컴포넌트에서 독립된 상태를 가진다고 생각하면된다.
하지만 관심사 분리도 엄청난 핵심 키워드라고 생각된다. 아래 1번의 코드만 봐도 하나의 컴포넌트에서 너무 많은 일을 담당하고 있을 때, 가독성, 유지보수, 재사용성이 크게 떨어진다. 따라서 UI, 비동기처리, 상태 변화 등과 같은 비즈니스 로직을 분리를 하고자 Presentational 컴포넌트와 Container 컴포넌트로 나누어서 비즈니스 로직을 Container 컴포넌트에서 수행하고, 그 결과를 Presentational 컴포넌트로 보내주어 렌더링해주는 방식의 디자인 패턴이 등장했지만, 함수 컴포넌트와 Hooks가 등장하면서 권장되지 않는 방법이 됐다고 한다.
그렇기 때문에 커스텀 훅을 통해 선언형 프로그래밍에 조금 더 가까워져 보기로 했다. 그럼 단계별로 나눠본 회원가입 폼을 작성해보면서 익숙해져보자!
1. 가장 기본적으로 작성해본 폼
맨처음 회원가입 폼을 아무런 컴포넌트와 분리없이 최대한 한 파일에다가 쭉 생각나는대로 작성해보았다. 물~론 정규식은 gpt형님 이용했습니다. 또한, 오류메세지들은 '모'회사의 홈페이지의 회원가입폼을 활용해보았습니다.
import { useState } from "react";
function Form() {
// 초기값 세팅
const [email, setEmail] = useState("");
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
// 오류메세지 상태 저장
const [emailMessage, setEmailMessage] = useState("");
const [idMessage, setIdMessage] = useState("");
const [passwordMessage, setPasswordMessage] = useState("");
const [passwordConfirmMessage, setPasswordConfirmMessage] = useState("");
// 유효성 검사 상태
const [isEmail, setIsEmail] = useState(false);
const [isId, setIsId] = useState(false);
const [isPassword, setIsPassword] = useState(false);
const [isPasswordConfirm, setIsPasswordConfirm] = useState(false);
const changeEmail = (e) => {
const currentEmail = e.target.value;
setEmail(currentEmail);
const emailRegExp =
/^[A-Za-z0-9_]+[A-Za-z0-9]*[@]{1}[A-Za-z0-9]+[A-Za-z0-9]*[.]{1}[A-Za-z]{1,3}$/;
if (!emailRegExp.test(currentEmail)) {
setEmailMessage("이메일 형식이 올바르지 않습니다.");
setIsEmail(false);
} else {
setEmailMessage("사용 가능한 이메일 입니다.");
setIsEmail(true);
}
};
const changeId = (e) => {
const currentId = e.target.value;
setId(currentId);
const idRegExp = /^[a-zA-Z0-9_]{6,}$/;
if (!idRegExp.test(currentId)) {
setIdMessage("6글자 이상의 영문자, 숫자, 특수기호(_)만 사용 가능합니다.");
setIsId(false);
} else {
setIsId(true);
setIdMessage("사용 가능한 아이디 입니다.");
}
};
const changePassword = (e) => {
const currentPassword = e.target.value;
setPassword(currentPassword);
const passwordRegExp = /^[a-zA-Z0-9_]{8,32}$/;
if (!passwordRegExp.test(currentPassword)) {
setPasswordMessage(
`8글자 이상 입력해 주세요. 입력하신 내용은 ${
password.length + 1
}글자입니다.`
);
setIsPassword(false);
} else {
setIsPassword(true);
setPasswordMessage("사용 가능한 비밀번호 입니다.");
}
};
const changePasswordConfirm = (e) => {
const currentConfirm = e.target.value;
setPasswordConfirm(currentConfirm);
if (password !== currentConfirm) {
setPasswordConfirmMessage("동일한 비밀번호를 입력해 주세요.");
setIsPasswordConfirm(false);
} else {
setIsPasswordConfirm(true);
setPasswordConfirmMessage("동일한 비밀번호 입니다.");
}
};
const submitHandler = (e) => {
e.preventDefault();
console.log("email: ", email);
console.log("id: ", id);
console.log("password: ", password);
console.log("passwordConfirm: ", passwordConfirm);
};
return (
<div>
<form action="#" onSubmit={submitHandler}>
<div className="form-el">
<label htmlFor="email">이메일</label>
<input type="text" id="email" value={email} onChange={changeEmail} />
<p className="message">{emailMessage}</p>
</div>
<div className="form-el">
<label htmlFor="id">아이디</label>
<input type="text" id="id" value={id} onChange={changeId} />
<p className="message">{idMessage}</p>
</div>
<div className="form-el">
<label htmlFor="password">비밀번호</label>
<input
type="password"
id="password"
value={password}
onChange={changePassword}
/>
<p className="message">{passwordMessage}</p>
</div>
<div className="form-el">
<label htmlFor="passwordConfirm">비밀번호 확인</label>
<input
type="password"
id="passwordConfirm"
value={passwordConfirm}
onChange={changePasswordConfirm}
/>
<p className="message">{passwordConfirmMessage}</p>
</div>
<button
type="submit"
disabled={!isEmail || !isId || !isPassword || !isPasswordConfirm}
>
회원가입
</button>
</form>
</div>
);
}
export default Form;
컴포넌트 활용도, 비즈니스 로직분리도 아무것도 사용하지 않고 한 파일에다가 작성해본 코드이다. 진짜 엄~청 길고 보기도 불편하고 흉하다. 하지만 일부러 이렇게 적은거니까 얼른 하나하나씩 바꿔보자.
2. 초기값 리팩토링
4개의 인풋(이메일, 아이디, 비밀번호, 비밀번호 확인) 중에 한개라도 변경하면 3개(값, 오류메세지, 유효성통과여부)의 state가 관여되기 때문에 렌더링이든 리팩토링을 할 때 매우 힘들어진다. 따라서 구조분해할당과 rest문법을 통해 바꿔주었다.
// 이전 코드
const [email, setEmail] = useState("");
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
// 이후 코드
const [form, setForm] = useState({
email: "",
id: "",
password: "",
passwordConfirm: "",
});
...
// 이전 코드
const changeEmail = (e) => {
const currentEmail = e.target.value;
setEmail(currentEmail); // <- 변경부분
const emailRegExp =
/^[A-Za-z0-9_]+[A-Za-z0-9]*[@]{1}[A-Za-z0-9]+[A-Za-z0-9]*[.]{1}[A-Za-z]{1,3}$/;
if (!emailRegExp.test(currentEmail)) {
setEmailMessage("이메일 형식이 올바르지 않습니다.");
setIsEmail(false);
} else {
setEmailMessage("사용 가능한 이메일 입니다.");
setIsEmail(true);
}
};
// 이후 코드
const changeEmail = (e) => {
const currentEmail = e.target.value;
setForm({ ...form, email: currentEmail }); // <- 변경부분
const emailRegExp =
/^[A-Za-z0-9_]+[A-Za-z0-9]*[@]{1}[A-Za-z0-9]+[A-Za-z0-9]*[.]{1}[A-Za-z]{1,3}$/;
if (!emailRegExp.test(currentEmail)) {
setEmailMessage("이메일 형식이 올바르지 않습니다.");
setIsEmail(false);
} else {
setEmailMessage("사용 가능한 이메일 입니다.");
setIsEmail(true);
}
};
...
위처럼 초기값을 form이라는 객체로 묶어주고, 객체의 프로퍼티로 이메일, 아이디, 비밀번호, 비밀번호 확인을 선언해주었다. 또한, 각각의 change함수를 통해 현재 프로퍼티의 인풋 값을 변경해주고, 나머지 프로퍼티는 rest문법으로 저장되어있는 기존값을 넣어주었다. 크게 어려운 부분은 아니니 넘어가겠다.
3. Input 컴포넌트 분리
이 단계도 이해하기 어려운 부분은 아닐 것이다. 회원가입 폼 특성상 input, label 등 반복이 많다. 따라서 하나의 field를 컴포넌트로 만들어서 재사용해주었다. 주 목적인 커스텀훅이 아니고, 많이 해본 과정이였기 때문에 이 부분도 빠르게 넘어가겠다.
// InputBox.jsx
function InputBox({ kind, title, shape, initial, change, message }) {
return (
<div className="form-el">
<label htmlFor={kind}>{title}</label>
<input
type={shape}
id={kind}
name={kind}
value={initial}
onChange={change}
/>
<p className="message">{message}</p>
</div>
);
}
export default InputBox;
// Form.jsx의 일부
return (
<div>
<form action="#" onSubmit={submitHandler}>
<InputBox
kind="email"
title="이메일"
shape="text"
initial={form.email}
change={changeEmail}
message={emailMessage}
/>
<InputBox
kind="id"
title="아이디"
shape="text"
initial={form.id}
change={changeId}
message={idMessage}
/>
<InputBox
kind="password"
title="비밀번호"
shape="password"
initial={form.password}
change={changePassword}
message={passwordMessage}
/>
<InputBox
kind="passwordConfirm"
title="비밀번호 확인"
shape="password"
initial={form.passwordConfirm}
change={changePasswordConfirm}
message={passwordConfirmMessage}
/>
<button
type="submit"
disabled={!isEmail || !isId || !isPassword || !isPasswordConfirm}
>
회원가입
</button>
</form>
</div>
);
4. useValid, useInput 커스텀훅
먼저, useValid 커스텀훅을 살펴보자.
// useValid.js
import { useEffect, useState } from "react";
export const useValid = (changeValue) => {
const [validText, setValidText] = useState({
emailMessage: "",
idMessage: "",
passwordMessage: "",
passwordConfirmMessage: "",
});
const [isValid, setIsValid] = useState({
isEmail: false,
isId: false,
isPassword: false,
isPasswordConfirm: false,
});
useEffect(() => {
const emailRegExp =
/^[A-Za-z0-9_]+[A-Za-z0-9]*[@]{1}[A-Za-z0-9]+[A-Za-z0-9]*[.]{1}[A-Za-z]{1,3}$/;
if (!emailRegExp.test(changeValue.email)) {
setValidText({
...validText,
emailMessage: "이메일 형식이 올바르지 않습니다.",
});
setIsValid({ ...isValid, isEmail: false });
} else {
setValidText({ ...validText, emailMessage: "" });
setIsValid({ ...isValid, isEmail: true });
}
}, [changeValue.email]);
useEffect(() => {
const idRegExp = /^[a-zA-Z0-9_]{6,}$/;
if (!idRegExp.test(changeValue.id)) {
setValidText({
...validText,
idMessage: "6글자 이상의 영문자, 숫자, 특수기호(_)만 사용 가능합니다.",
});
setIsValid({ ...isValid, isId: false });
} else {
setValidText({ ...validText, idMessage: "" });
setIsValid({ ...isValid, isId: true });
}
}, [changeValue.id]);
useEffect(() => {
const passwordRegExp = /^[a-zA-Z0-9_]{8,32}$/;
if (!passwordRegExp.test(changeValue.password)) {
setValidText({
...validText,
passwordMessage: `8글자 이상 입력해 주세요. 입력하신 내용은 ${changeValue.password.length}글자입니다.`,
});
setIsValid({ ...isValid, isPassword: false });
} else {
setIsValid({ ...isValid, isPassword: true });
setValidText({
...validText,
passwordMessage: "",
});
}
}, [changeValue.password]);
useEffect(() => {
if (changeValue.password !== changeValue.passwordConfirm) {
setValidText({
...validText,
passwordConfirmMessage: "동일한 비밀번호를 입력해 주세요.",
});
setIsValid({ ...isValid, isPasswordConfirm: false });
} else {
setIsValid({ ...isValid, isPasswordConfirm: true });
setValidText({
...validText,
passwordConfirmMessage: "",
});
}
}, [changeValue.passwordConfirm]);
return [ validText, isValid ];
};
Form컴포넌트에서는 validText, isValid 상태가 어떤 방식으로 바뀌는지는 궁금하지 않을 것이다. 무엇을 보여주고 , 무슨 행동을 할지에 대한 내용만 있다면 무엇을 렌더링해야 하는지 더 확실하게 알 수 있을 것이다. 따라서 유효성검사에 대한 로직을 커스텀 훅을 통해 view 컴포넌트와 분리하여 무슨 message가 렌더링 되어야 하는지만 view 컴포넌트로 전달해준다면 렌더링에 필요한 데이터들만 쉽게 확인할 수 있을 것이다. 비록 해당 유효성검사 로직이 재사용되지 않더라도 렌더링 요소와 비즈니스 로직을 분리하는 것으로도 충분히 가치가 있다고 생각했다.
// useInput.js
import { useState } from "react";
export const useInput = (initialValue) => {
const [data, setData] = useState(initialValue);
const onChange = (e) => {
const { name, value } = e.target;
setData({ ...data, [name]: value });
};
return [data, onChange];
};
useInput 커스텀 훅도 useValid 커스텀 훅과 마찬가지다. 이 훅도 useValid처럼 코드설명은 따로 하지 않아도 충분히 이해 할만한 난이도일거라고 생각한다. 원래 1번코드에서 볼 수 있는 계속 반복된 change함수들을 하나로 묶어서 만든 커스텀 훅 useInput의 반환값인 data와 onChange를 input의 value속성과 onChange이벤트로 보내주었다.
5. 최종 Form.jsx 코드
// Form.jsx
import { useInput } from "../hooks/useInput";
import { useValid } from "../hooks/useValid";
import InputBox from "../inputBox/InputBox";
import "./form.scss";
function Form() {
// 초기값 세팅
const form = {
email: "",
id: "",
password: "",
passwordConfirm: "",
};
const [data, onChange] = useInput(form);
const [validText, isValid] = useValid(data);
const submitHandler = (e) => {
e.preventDefault();
console.log("email: ", data.email);
console.log("id: ", data.id);
console.log("password: ", data.password);
console.log("passwordConfirm: ", data.passwordConfirm);
};
return (
<div className="container">
<form action="#" onSubmit={submitHandler}>
<InputBox
kind="email"
title="이메일"
shape="text"
initial={data.email}
change={onChange}
message={validText.emailMessage}
/>
<InputBox
kind="id"
title="아이디"
shape="text"
initial={data.id}
change={onChange}
message={validText.idMessage}
/>
<InputBox
kind="password"
title="비밀번호"
shape="password"
initial={data.password}
change={onChange}
message={validText.passwordMessage}
/>
<InputBox
kind="passwordConfirm"
title="비밀번호 확인"
shape="password"
initial={data.passwordConfirm}
change={onChange}
message={validText.passwordConfirmMessage}
/>
<button
type="submit"
disabled={
!isValid.isEmail ||
!isValid.isId ||
!isValid.isPassword ||
!isValid.isPasswordConfirm
}
>
회원가입
</button>
</form>
</div>
);
}
export default Form;
나머지 InputBox 컴포넌트와 2개의 커스텀훅은 3,4 번에서 확인할 수 있는 코드가 최종 코드다. 최종 Form컴포넌트를 보면 처음 1번코드와 비교해봤을 때 한개의 컴포넌트 내에 많은 기능을 주지 않고 관심사 분리를 통해 컴포넌트가 더 다양해지고, 복잡해질수록 에러 발생 가능성을 줄여주고, 유지보수 시간이 결과적으로는 더 적어질 것이라고 생각한다. 하지만 커스텀 훅에 대한 주의 사항도 있다.
- 추상화를 해서 코드를 작성했다면 추상화의 레벨이 적절하게 이뤄졌는가?
- 커스텀 훅을 사용하지 않아도 되는 부분에서 사용하지 않았는가?
- 커스텀 훅이 한가지 기능만을 하고 있는가?
- 사이드 이펙트를 일으킬 부분이 있지 않는가?
커스텀 훅이 자유로운 만큼 분명히 해야할 부분도 있다는 것이다. 내가 만든 커스텀 훅도 사실 납득할만한 코드인지 확신을 가지지 못하였다. 하지만 나의 입장에서는 비즈니스 로직과 뷰를 분리하고, 선언형 프로그래밍을 하는 가장 큰 목적인 관심사 분리를 통해 컴포넌트를 빠르게 파악하고, 수정할 부분을 찾기위해 커스텀 훅으로 사용할만 하다고 생각하였다. 결론적으로 추상화와 이해하기 좋은 코드를 작성할 수 있도록 더 많은 연습과 고민이 필요해 보인다. 다음 진행해볼 투두리스트도 이번과 똑같이 처음엔 최대한 분리와 검색없이 작성해보고 하나씩 분리해 나가보자.