내가 이전에 작성했던 코드를 보고 비즈니스 로직과 UI의 얽힌 부분을 풀어서 캡슐화를 진행해보려고 한다.
비즈니스 로직 찾아보기
예전에 작성했던 코드 중에 너무 복잡한건 실습 하다가 그만 둘 것 같아서 난이도가 가장 낮은 Join 컴포넌트로 실습을 하기로 했다.
일단 처음에 코드를 쭉 살펴보면서 비즈니스 로직으로 생각할 수 있는 것을 찾아보았다.
2 const [email, setEamil] = useState("");
3 const [userName, setUserName] = useState("");
4 const [password, setPassword] = useState("");
5
6 const {
7 isEmail,
8 setIsEmail,
9 isUserName,
10 setIsUserName,
11 isName,
12 setIsName,
13 isPassword,
14 setIsPassword,
15 isPassword2,
16 setIsPassword2,
17 isDisabled,
18 setIsDisabled
19 } = useValidate();
20
21 const navigate = useNavigate();
22
23 const {
24 register,
25 handleSubmit,
26 formState: { errors },
27 setError
28 } = useForm<SubmitProps>();
30 const { mutate: joinMutate, isSuccess } = useJoin();
32 const onSubmit = handleSubmit(async (data: SubmitProps) => {
33 if (data.password !== data.password2) {
34 setError(
35 "password2",
36 {
37 message: "앞에서 입력한 비밀번호와 같아야합니다."
38 },
39 { shouldFocus: true }
40 );
41 return;
42 }
43 joinMutate(data);
44 });
46 const checkValueFromServer = async (value: string, type: "email" | "userName") => {
47 try {
48 const check = await axios.get(`/api/user/checked-db?${type}=${value}`);
49
50 const { exist } = check.data;
51
52 if (!exist) {
53 return true;
54 }
55
56 throw check;
57 } catch (e) {
58 return false;
59 }
60 };
61
62 const checkChangeValueForValidate = ({ event, type }: CheckValue) => {
63 const value = event.currentTarget.value;
64 const checkValue = VALIDATION_CHECK_VALUE[`${type}`].value.test(value);
65 return checkValue;
66 };
67
68 const validateEmail = async (e: React.ChangeEvent<HTMLInputElement>) => {
69 setEamil(e.currentTarget.value);
70
71 const checkValue = checkChangeValueForValidate({
72 event: e,
73 type: "email"
74 });
75 if (checkValue) {
76 const canUseValue = await checkValueFromServer(e.currentTarget.value, "email");
77 if (canUseValue) {
78 setIsEmail(canUseValue);
79 setError("email", { message: "" });
80 } else {
81 setIsEmail(canUseValue);
82 setError("email", {
83 message: "이미 다른 사람이 사용중이에요."
84 });
85 }
86 }
87 if (isEmail !== null && !checkValue) {
88 setIsEmail(false);
89 setError("email", { message: VALIDATION_CHECK_VALUE.email.message });
90 }
91 };
92
93 const validateUserName = async (e: React.ChangeEvent<HTMLInputElement>) => {
94 setUserName(e.currentTarget.value);
95 const checkValue = checkChangeValueForValidate({
96 event: e,
97 type: "userName"
98 });
99 if (checkValue) {
100 const canUseValue = await checkValueFromServer(e.currentTarget.value, "userName");
101 if (canUseValue) {
102 setIsUserName(canUseValue);
103 setError("userName", { message: "" });
104 } else {
105 setIsUserName(canUseValue);
106 setError("userName", {
107 message: "이미 다른 사람이 사용중이에요."
108 });
109 }
110 }
111 if (isUserName !== null && !checkValue) {
112 setIsUserName(false);
113 setError("userName", {
114 message: VALIDATION_CHECK_VALUE.userName.message
115 });
116 }
117 };
118
119 useEffect(() => {
120 if (isEmail && isPassword && isPassword2 && isUserName && isName) {
121 setIsDisabled(false);
122 return;
123 }
124 setIsDisabled(true);
125 }, [isEmail, isPassword, isPassword2, isUserName, isName]);
126
127 useEffect(() => {
128 if (isSuccess) {
129 const timeout = setTimeout(() => navigate("/login"), 3000);
130 return () => clearTimeout(timeout);
131 }
132 }, [isSuccess]);
133 if (isSuccess) {
134 return (
135 <Wrapper>
136 <MessageContainer>
137 <h1>가입하신것을 축하드립니다! 😙</h1>
138 <p>로그인 화면으로 이동합니다.</p>
139 </MessageContainer>
140 </Wrapper>
141 );
142 }
192 onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
193 const checkValue = checkChangeValueForValidate({
194 event: e,
195 type: "name"
196 });
197 if (checkValue) {
198 setIsName(checkValue);
199 setError("name", { message: "" });
200 }
201 if (isName !== null && !checkValue) {
202 setIsName(false);
203 setError("name", {
204 message: VALIDATION_CHECK_VALUE.name.message
205 });
206 }
207 }
208 })}
209 />
210 </RootFormItem>
211 <ErrorLabel error={isName}>{isName === null ? VALIDATION_CHECK_VALUE.name.message : errors?.name?.message}</ErrorLabel>
212 </FormItemContainer>
213 <FormItemContainer>
214 <RootFormItem error={isPassword}>
215 <Label>비밀번호</Label>
216 <Input
217 type="password"
218 {...register("password", {
219 required: VALIDATION_CHECK_VALUE.joinPassword.message,
220 pattern: VALIDATION_CHECK_VALUE.joinPassword,
221 onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
222 setPassword(e.currentTarget.value);
223 const checkValue = checkChangeValueForValidate({
224 event: e,
225 type: "joinPassword"
226 });
227 if (checkValue) {
228 setIsPassword(checkValue);
229 setError("password", { message: "" });
230 }
231 if (isPassword !== null && !checkValue) {
232 setIsPassword(false);
233 setError("password", {
234 message: VALIDATION_CHECK_VALUE.joinPassword.message
235 });
236 }
237 }
238 })}
239 />
240 </RootFormItem>
241 <ErrorLabel error={isPassword}>
242 {isPassword === null ? VALIDATION_CHECK_VALUE.joinPassword.message : errors?.password?.message}
243 </ErrorLabel>
244 </FormItemContainer>
245 <FormItemContainer>
246 <RootFormItem error={isPassword2}>
247 <Label>비밀번호 확인</Label>
248 <Input
249 id="password2"
250 type="password"
251 {...register("password2", {
252 required: VALIDATION_CHECK_VALUE.password2.message,
253 onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
254 if (password === e.currentTarget.value) {
255 setIsPassword2(true);
256 setError("password2", { message: "" });
257 return;
258 }
259 setIsPassword2(false);
260 setError("password2", {
261 message: VALIDATION_CHECK_VALUE.password2.message
262 });
263 }
264 })}
265 />
266 </RootFormItem>
267 <ErrorLabel error={isPassword2}>
268 {isPassword2 === null ? "앞에서 입력한 비밀번호와 같은 값을 입력해주세요." : errors?.password2?.message}
269 </ErrorLabel>
270 </FormItemContainer>
271 <SubmitButton
272 buttonType="block"
273 disabled={isDisabled}
274 >
275 가입하기
276 </SubmitButton>
277 </form>
278 </Wrapper>
279 );
280}
281export default Join;
비즈니스 로직이라고 생각하는 이유
비즈니스 로직은 UI 컴포넌트의 기본 기능 외, 사용자의 액션을 반영해서 추가적으로 사이드이펙을 일으키는 요소라고 이해했다.
예를들면 Text Input 컴포넌트의 기본 기능은 사용자가 키보드로 문자열을 입력했을 때 안에 글자가 들어가는 것이라면 비즈니스 로직은
문자열을 입력했을 때, 비즈니스가 요구하는 형식에 맞는지 validation을 해주고 아니라면 색상을 변경하거나 메시지를 사용자에게 노출시켜
경고나 안내를 하는 로직이다. Join.tsx에서 밝게 표시된 영역들은 그러한 이유에서 비즈니스 로직이라 생각하는 것들이다.
UI 로직에서 비즈니스 로직 분리해보기
동작 방법
내가 실습으로 고른 코드는 비즈니스 로직이 생각보다 많이 얽혀있는 것 같진 않다.
일단 이 컴포넌트의 동작 순서 또는 방법에 대해서 간략하게 정리해보자.
- 사용자가 input에 값을 넣는다.
- react hook form의 onChange에서 변경되는 값을 setState를 사용해 값을 넣어준다.
- validateEmail 이나 validateUserName이 동작한다. (email, userName일 때)
- 계산(Validation) 후 액션 (isEmail, isUserName)
- 비밀번호는 함수가 없지만 input 컴포넌트 안에서 onChange에 로직이 있다.
- 모든 값을 알맞게 입력하면 submit 버튼 disabled가 false로 변경
- useEffect에서 액션을 일으킨다.
- isEmail, isUserName,isName,isPassword 등이 true가 된다. 그리고 isDisabled가 false가 된다.
- onSubmit 버튼을 누르면 최종적으로 서식을 보내기 전에 password === password2인지 체킹힌다.
- (혼란스럽다... 왜 여기서...??) 어쨌든 계산 후 joinMutate 함수를 호출한다 (액션)
- 최종적으로 isSuccess가 true이면 navigate 함수가 동작한다. (액션)
validation 기능의 역할 강화하기
이 컴포넌트가 복잡한 이유를 두 가지로 분석해봤는데 일단 첫번째로 validation이 이곳 저곳에 흩어져있기 때문이다.
useValidate라는 훅은 정말 아무런 역할을 하지 않는다. 그냥 단순하게 isEmail, setIsEmail을 추출한 훅이다.
아마도 이때 당시에 훅을 이렇게 작성한 이유는 isEmail이라는 boolean이 너무 많아서 따로 뽑은 듯 하다.
하지만 이상하게도 useEffect가 밖에 있고 조건부 절 안에서 isDisabled를 세팅하고 있다.
validation을 하는 함수도 email과 username을 제외하고는 나머지는 컴포넌트에 뒤섞여있다.
그래서 일단 validation 역할을 모아서 강화해보기로했다. 그럼 useValidate를 이렇게 묶을 수 있다.
1const useValidate = () => {
2 const [{ isEmail, isUserName, isName, isPassword, isPassword2 }, dispatch] =
3 useReducer(reducer, initValue);
4
5 const isDisabled = Object.entries(validate).every(([_, value]) => value);
6
7 const checkChangeValueForValidate = ({ value, type }: CheckValue) => {
8 const checkValue = VALIDATION_CHECK_VALUE[`${type}`].regex.test(value);
9 return checkValue;
10 };
11
12 return {
13 validate: {
14 isEmail,
15 isUserName,
16 isName,
17 isPassword,
18 isPassword2
19 },
20 dispatch,
21 checkChangeValueForValidate
22 };
23};
24
25export default useValidate;
isDisabled는 함수가 랜더링 될때 변경되기 때문에 useEffect를 사용해서 값을 셋 할 필요가 없다.
checkChangeValueForValidate는 원래 event를 받도록 되어있는데 이러면 이벤트에 종속되기 때문에 value를 string으로 받도록 변경했다.
그럼 value가 어떤 이벤트인지는 별로 중요해지지 않고 validate를 하려는 곳에서 언제든지 사용할 수 있다.
다음으로 validate*** 함수가 onChange의 역할을 대신하고 있는 것이 문제가 된다.
1const validateUserName = async (e: React.ChangeEvent<HTMLInputElement>) => {
2 setUserName(e.currentTarget.value);
3 const checkValue = checkChangeValueForValidate({
4 event: e,
5 type: "userName"
6 });
7 if (checkValue) {
8 const canUseValue = await checkValueFromServer(
9 e.currentTarget.value,
10 "userName"
11 );
12 if (canUseValue) {
13 setIsUserName(canUseValue);
14 setError("userName", { message: "" });
15 } else {
16 setIsUserName(canUseValue);
17 setError("userName", {
18 message: "이미 다른 사람이 사용중이에요."
19 });
20 }
21 }
22 if (isUserName !== null && !checkValue) {
23 setIsUserName(false);
24 setError("userName", {
25 message: VALIDATION_CHECK_VALUE.userName.message
26 });
27 }
28 };
29
30return <FormItemContainer>
31 <RootFormItem error={isUserName}>
32 <Label>사용자 이름</Label>
33 <Input
34 type="text"
35 value={userName}
36 {...register("userName", {
37 required: VALIDATION_CHECK_VALUE.userName.message,
38 pattern: VALIDATION_CHECK_VALUE.userName,
39 onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
40 validateUserName(e)
41 })}
42 />
43 </RootFormItem>
44 <ErrorLabel error={isUserName}>
45 {isUserName === null
46 ? "사용자 이름은 한글, 영문, 숫자 조합 5자 이상 10자 이하로 입력해주세요."
47 : errors?.userName?.message}
48 </ErrorLabel>
49 </FormItemContainer>
onChange는 그냥 단순하게 onChange를 하도록 하고
react-hook-form을 사용했으니까 react-hook-form을 통해서 validation을 하는 방향으로 변경하려고 한다.
이렇게 변경점을 생각한 이유는 react-hook-form을 사용하는데 Input은 controlled 컴포넌트다.
그럼 react-hook-form의 이점은 살리기 어렵기 때문에 그냥 라이브러리를 가져다가 쓴 격이 된다. 그래서 react-hook-form을 사용해서
컴포넌트 랜더링을 최적화 한다. 그럼, useState와 onChange의 역할을 대신하고 있는 validate*** 함수는 더이상 역할이 없기 때문에 삭제할 수 있다.
1const Join = ()=>{
2
3 return (
4 <Wrapper>
5 <h1>회원가입</h1>
6 <form onSubmit={onSubmit}>
7 <FormItemContainer>
8 <RootFormItem error={isEmail}>
9 <Label>이메일</Label>
10 <Input
11 type="text"
12 {...register("email", {
13 required: VALIDATION_CHECK_VALUE.email.errorMessage,
14 pattern: VALIDATION_CHECK_VALUE.email.regex
15 })}
16 />
17 </RootFormItem>
18 <ErrorLabel error={isEmail}>
19 {isEmail === null
20 ? "사용하고 있는 이메일을 입력해주세요."
21 : errors?.email?.message}
22 </ErrorLabel>
23 </FormItemContainer>
24 // 형식이 같기 때문에 생략
25 </form>
26 </Wrapper>
27)
28}
하지만 이렇게 되면 문제가 되는 것은 react-hook-form을 사용해서 사용자가 입력하는 값을 실시간으로 받아야하는 점이 문제가 된다.
그렇게 되면 필연적으로 useValidate 훅은 react-hook-form에 의존적이게 될 수 밖에 없다. 그럼 react-hook-form의 useForm, useWatch 의 값을
useValidate에 props로 넣어주어야하는데 이렇게 하면 응집도가 느슨해지고 또 복잡해진다.
그래서 나는 useValidate를 useJoinForm으로 이름을 바꾸고 useForm의 역할의 extends해서 Join.tsx에 제공해주는 쪽으로 리펙토링을 했다.
1const useJoinForm = () => {
2 const [{ isEmail, isUserName, isName, isPassword, isPassword2 }, dispatch] =
3 useReducer(reducer, initValue);
4 const { control, ...rest } = useForm<FormValues>();
5
6 const { email, name, password2, password, userName } = useWatch({
7 control
8 });
9
10 const isDisabled = !Object.entries({
11 isEmail,
12 isName,
13 isPassword,
14 isPassword2,
15 isUserName
16 }).every(([_, value]) => value);
17
18 const checkChangeValueForValidate = ({ value, type }: CheckValue) => {
19 if (!value) {
20 return;
21 }
22 const checkValue = VALIDATION_CHECK_VALUE[`${type}`].regex.test(value);
23 return checkValue;
24 };
25
26 const validateEmail = useCallback(() => {
27 if (!email) {
28 dispatch({
29 type: "SET_IS_EMAIL",
30 payload: null
31 });
32 return;
33 }
34 dispatch({
35 type: "SET_IS_EMAIL",
36 payload: Boolean(
37 checkChangeValueForValidate({ type: "email", value: email })
38 )
39 });
40 }, [email]);
41
42 const validateUserName = useCallback(() => {
43 if (!userName) {
44 dispatch({
45 type: "SET_IS_USERNAME",
46 payload: null
47 });
48 return;
49 }
50 dispatch({
51 type: "SET_IS_USERNAME",
52 payload: Boolean(
53 checkChangeValueForValidate({ type: "userName", value: userName })
54 )
55 });
56 }, [userName]);
57
58 const validateName = useCallback(() => {
59 if (!name) {
60 dispatch({
61 type: "SET_IS_NAME",
62 payload: null
63 });
64 return;
65 }
66 dispatch({
67 type: "SET_IS_NAME",
68 payload: Boolean(
69 checkChangeValueForValidate({ type: "name", value: name })
70 )
71 });
72 }, [name]);
73
74 const validatePassword = useCallback(() => {
75 if (!password || !password2) {
76 dispatch({
77 type: "SET_IS_PASSWORD",
78 payload: null
79 });
80 dispatch({
81 type: "SET_IS_PASSWORD2",
82 payload: null
83 });
84 return;
85 }
86 dispatch({
87 type: "SET_IS_PASSWORD",
88 payload: Boolean(
89 checkChangeValueForValidate({ type: "password", value: password })
90 )
91 });
92 dispatch({
93 type: "SET_IS_PASSWORD2",
94 payload: Boolean(
95 checkChangeValueForValidate({ type: "password2", value: password2 })
96 )
97 });
98
99 if (password !== password2) {
100 dispatch({
101 type: "SET_IS_PASSWORD2",
102 payload: false
103 });
104 return;
105 }
106 }, [password, password2]);
107
108 const validateValue = () => {
109 validateEmail();
110 validateName();
111 validateUserName();
112 validatePassword();
113 };
114
115 useEffect(() => {
116 validateValue();
117 }, [email, password, password2, name, userName]);
118
119 return {
120 validate: {
121 isEmail,
122 isUserName,
123 isName,
124 isPassword,
125 isPassword2,
126 isDisabled
127 },
128 formValues: {
129 email,
130 name,
131 password2,
132 password,
133 userName
134 },
135 formMethod: {
136 control,
137 ...rest
138 },
139 dispatch,
140 checkChangeValueForValidate
141 };
142};
143
144export default useJoinForm;
validate 함수를 각자 따로 만들어서 처리한 것은 각 함수에 서버에 요청을 보내야하는 로직도 들어가야하고 에러 처리도 다 다르기 때문에
함수를 따로 만들었다. 하지만 나머지 로직이 들어간 뒤에 반복되는 부분을 부분 캡슐화 하면 조금더 간략하게 로직을 처리할 수 있을지도 모른다.
ErrorLabel은 에러만 표시하게 해주세요.
Join.tsx를 보면 ErrorLabel의 쓰임새가 좀 어색하다.
1<ErrorLabel error={isEmail}>{isEmail === null ? "사용하고 있는 이메일을 입력해주세요." : errors?.email?.message}</ErrorLabel>
일단 isEamil이 null인 이유를 파악하기 어렵다. 그리고 ErrorLabel인데 error를 props로 받고 있고 isEmail의 validation된 값을 받는다.
이름도 어렵고 파악하기 어렵다. 코드를 리펙토링하면서 알게 된것인데 UI상에서는 isEmail이 null일때
무상태로 표시된다.(검정색) 그러다 사용자가 입력을 시작하면 형식에 맞지 않기 때문에 isEmail이 false가 되고
빨간색이 된다. 형식에 맞게 입력을 하면 isEmail은 true가 되기 때문에 초록색으로 바뀐다.
이 역할을 수행하기 위해서 ErrorLabel은 isEmail을 받아 여러 상태를 분기처리하도록 되어있다.
차라리 ErrorLabel은 이름처럼 에러 메시지만 보여주고 대신 GuideLabel을 만들어서 상태 없을때 사용자에게
어떻게 입력하라는 가이드 메시지를 표시해주는게 더 낫지 않을까 생각했다.
이렇게 하면 비즈니스 로직과 컴포넌트가 강하게 결합되는 것을 막을 수 있다고 생각했다. ErrorLabel은 굳이 다른 곳에서 boolean을 받지 않아도 되기 때문에
그냥 에러일 때 에러인 메시지를 다른 곳에서도 사용자에게 보여줄 수 있다. 오히려 변화를 주어서 컴포넌트가 하나 더 늘고 조건식이 복잡해지긴 했지만 UI에 비즈니스 로직이 종속되지 않기 때문에 더 낫다고 생각한다.
개선 전과 후
Join.tsx와 useJoinForm.tsx
을 보면 이전과 이후가 그렇게 크게 변경된 것 같지 않다. 오히려 코드량이 늘었다. 하지만 단순 추출이 아니라 기능을 캡슐화 했기 때문에 다른 곳에서도 useJoinForm을 사용할 수 있다.(이름이 useJoinForm이라 다른 곳에서 사용하면 좀 띠용하지만)
Join은 비즈니스 로직이 View에서 제거되었기 때문에 변경을 해야한다고 하면 쉽게 변경이 가능하다.
지금 햇갈리는 부분은 반복되는 뷰 로직을 적절하게 처리하지 못했는데 Repeat.tsx를 정규화를 해서 반복을 줄였어야 했나 싶다.
1<FormItemContainer>
2 <RootFormItem error={isUserName}>
3 <Label>사용자 이름</Label>
4 <Input
5 type="text"
6 {...register("userName", {
7 required: VALIDATION_CHECK_VALUE.userName.errorMessage,
8 pattern: VALIDATION_CHECK_VALUE.userName.regex
9 })}
10 />
11 </RootFormItem>
12
13 {isUserName === null ? <GuideLabel>{GUIDE_MESSAGE.userName}</GuideLabel> : null}
14 {isUserName !== null && !isUserName ? <ErrorLabel>{errors?.userName?.message}</ErrorLabel> : null}
15</FormItemContainer>
FormItemContainer.tsx
와 같이 똑같이 반복되기 때문에 FormItemContainer.tsx
를 컴포넌트로 따로 격리하고 반복을 줄였다면 어땠을까?
하지만 Idea.tsx
처럼 하게되면 FormItemContainer
는 변경점이 있을 때마다 많은 고민을 해야한다. 유연함이 없어지기 때문에 예외 상황이 생겼을 때 props를 하나 더 받아야하고
props에 따라 FormItemContainer
를 분기처리해야하는 상황이 생긴다. 그래서 고민하다 그냥 FormItemContainer
라는 컴포넌트를 만들기보다.
디테일이 변경되었을 때 대응하기 쉬운 방향으로 리펙토링을 끝마쳤다.
복잡한 건 복잡할 수 밖에 없다.
도널드 노먼은 현대 기술의 혼란스러움에서 비롯된 좌절을 줄이기 위해 단순한 상황을 고려한 심플한 제품은 제품의 해결책이 될 수 없다고 지적한다.
오히려 풍부하고 만족스러운 삶을 추구하는 우리에겐 복잡함이 필요하다고 말한다. 그는 나쁜 것은 복잡함이 아니라 혼란스러움이라고 정의한다.
코드를 작성하다보면 복잡성이 높은 어플리케이션을 만들게 된다. 그럼 당연히 코드도 복잡해질 수밖에 없다. 하지만 코드가 혼란스러워서는 안된다.
1000줄이 넘어가는 복잡한 코드도 캡슐화가 잘 되어있다면 특정 부분을 수정하는 것이 그렇게 어렵지는 않을 것이다. 하지만 혼란스러운 코드는
정말 많은 시간을 들여서 코드를 읽어야하고 수정도 쉽지 않으며 수정 이후 어떤 일이 벌어질지 전혀 예측할 수 없다. 그래서 제품을 테스트하는데 정말 많은 시간을 들여야하고
그렇게 했음에도 불구하고 에러가 발생한다.
이번에 Join.tsx
를 리펙토링 하면서 방향을 잡기가 쉽지는 않았다. 하지만 일단 코드를 읽고 코드의 동작 방법을 적어보고 큰 부분으로 나누고 다시 코드를 작성하고를 반복하면서
나름 좋은 코드가 무엇일까 고민해보는 시간을 가졌다. 내일 회사 나가면 주어진 업무가 있는데 그 업무를 진행할 때 일단 키보드부터 두드리기 전에
가볍에 요구사항을을 큰 부분으로 나눠보고 의사 코드를 작성한 다음에 일을 시작해보려고 한다. 그럼 혼란스러움은 피할 수 있지 않을까?
피드백 적용하기
맨토님의 피드백이 있었다. 일단 피드백 전체 내용은 리펙토링 이후에 오히려 복잡해진 느낌이 든다는 것과 useJoinForm 함수를 RHF와 zod, yup 등 라이브러리를 써서 validation을 할 수 있다는 정보를 주셨다.
우왕! 피드백이 있다면 해보는게 인지상정.
일단 RHF 공식 페이지에 보니 yup과 함께 쓰는 방법이 소개 되어있었다.
나는 yup은 잘 모르니까 zod로 해보기로 했다. zod도 서버에서 타입 체크 하는 라이브러리라고만 알고 있었는데 GPT에게 물어보니 다음과 같이 대답해주었다.
Q. zod란?
Zod은 TypeScript를 위한 스키마 선언 및 유효성 검사 라이브러리입니다. 이 라이브러리는 TypeScript에서의 타입 안전성을 강화하며, 사용자가 정의한 스키마에 따라 데이터를 검증하는 기능을 제공합니다. Zod를 사용하면 복잡한 데이터 구조의 유효성을 쉽게 검증할 수 있으며, 이는 API 응답, 사용자 입력 데이터 등 다양한 분야에서 유용하게 사용됩니다. TypeScript의 타입 시스템과 밀접하게 통합되어 있어, 타입스크립트 개발자들에게 매우 유용한 도구로 자리잡고 있습니다.
Q.zod는 런타임 환경에서도 타입 체크를 해주는거야?
네, 맞습니다. Zod는 런타임 환경에서 타입 체크를 제공합니다. 일반적으로 TypeScript는 컴파일 시에 타입을 체크하고, 런타임에는 타입 정보가 제거되므로, 런타임에서의 타입 체크는 TypeScript 자체로는 수행할 수 없습니다. 그러나 Zod는 이를 보완하여 런타임에서도 데이터의 구조와 타입을 검증할 수 있게 해줍니다. Zod를 사용하면, 개발자가 정의한 스키마에 따라 입력된 데이터가 해당 스키마와 일치하는지 검증할 수 있습니다. 예를 들어, API 응답이나 사용자 입력 데이터 등이 예상한 타입과 구조를 가지고 있는지 확인할 수 있습니다. 이를 통해 개발자는 더 안정적이고 예측 가능한 코드를 작성할 수 있으며, 잠재적인 타입 관련 오류를 미연에 방지할 수 있습니다.
Q. 스키마가 뭐지?
스키마(schema)는 데이터의 구조, 형식, 그리고 규칙을 정의하는 일종의 설계도나 청사진입니다. 프로그래밍과 데이터 관리에서 스키마는 다음과 같은 역할을 합니다.
- 구조 정의: 스키마는 데이터가 어떤 필드를 포함할지, 그 필드들이 어떤 데이터 타입을 가져야 하는지 정의합니다. 예를 들어, 사용자 정보 스키마는 이름, 이메일, 나이 등의 필드와 각 필드의 타입(문자열, 숫자 등)을 명시할 수 있습니다.
- 유효성 검사 규칙 설정: 스키마는 데이터가 특정 조건을 충족해야 한다는 규칙도 포함할 수 있습니다. 예를 들어, 이메일 필드에 대해 유효한 이메일 형식을 가져야 한다거나, 나이 필드가 양의 정수여야 한다는 등의 규칙을 설정할 수 있습니다.
- 데이터 무결성 유지: 스키마를 통해 데이터의 정확성과 일관성을 유지할 수 있습니다. 스키마에 따라 데이터가 저장되고 처리되면, 잘못된 형식이나 불완전한 데이터가 시스템에 들어오는 것을 방지할 수 있습니다.
프로그래밍에서, 특히 데이터베이스 관리나 API 개발에서 스키마는 중요한 역할을 합니다. 예를 들어, 데이터베이스 스키마는 테이블의 구조, 관계, 제약 조건 등을 정의하며, API에서는 요청과 응답 데이터의 형식을 정의하는 데 사용됩니다. Zod 같은 라이브러리를 사용하면, 이러한 스키마를 쉽게 정의하고, 런타임에 데이터가 이 스키마를 따르는지 검증할 수 있습니다.
왜 express에서 body를 받을 때, zod와 typescript를 사용해서 스키마를 만들고 유효성 검사를 했는지 조금은 알 것 같았다.
이 포스트에서 zod 사용 방법을 자세하게 쓰지는 않을 것이다. 일단 React Hook Form: Schema validation using Zod라는 글을 읽었는데
이곳에서 너무 잘 설명을 해주고 있기 때문에 기본적인 사용 방법은 이곳에서 익히면 될 것 같다.
zod를 적용하다보니 ErrorLabel과 GuidLabel
을 추상화를해야겠다는 생각이 들었다. 변경점이 있을 때마다 반복해서 이름을 바꿔주는게 힘들었다.
그래서 ValidationLabel.tsx
를 만들었다. validationType만 외부에서 주입해주면 타입에 따라 Guid와 Error 메시지를 변경해준다.
1import { ValidationSchema } from "@/Pages/Root/hooks/useJoinForm";
2import { GUIDE_MESSAGE } from "@/Pages/Root/lib/guideMessage";
3
4type ValidationType = keyof typeof GUIDE_MESSAGE;
5
6interface ValidationLableProps {
7 validationType: ValidationType;
8 errors: FieldErrors<ValidationSchema>;
9 formState?: boolean | null;
10}
11
12const ValidationLabel = ({
13 validationType,
14 errors,
15 formState
16}: ValidationLableProps) => {
17 return !formState ? (
18 <>
19 {!errors[validationType] ? (
20 <GuideLabel>{GUIDE_MESSAGE[validationType]}</GuideLabel>
21 ) : null}
22 {errors[validationType] ? (
23 <ErrorLabel>{errors[validationType]?.message}</ErrorLabel>
24 ) : null}
25 </>
26 ) : null;
27};
28
29export default ValidationLabel;
최종적으로 변경된 코드는 다음과 같다. 확실히 useReducer가 사라지고나니 validation에 복잡함이 제거되었다.
1
2function Join() {
3 const navigate = useNavigate();
4
5 const { mutate: joinMutate, isSuccess } = useJoin();
6
7 const {
8 formMethod: {
9 handleSubmit,
10 register,
11 formState: { errors }
12 },
13 fieldState: { email, name, password, password2, userName },
14 isDisabled
15 } = useJoinForm();
16
17 const onSubmit = handleSubmit(async (data) => {
18 joinMutate(data);
19 });
20
21 useEffect(() => {
22 if (isSuccess) {
23 const timeout = setTimeout(() => navigate("/login"), 3000);
24 return () => clearTimeout(timeout);
25 }
26 }, [isSuccess]);
27
28 if (isSuccess) {
29 return (
30 <Wrapper>
31 <MessageContainer>
32 <h1>가입하신것을 축하드립니다! 😙</h1>
33 <p>로그인 화면으로 이동합니다.</p>
34 </MessageContainer>
35 </Wrapper>
36 );
37 }
38
39 return (
40 <Wrapper>
41 <h1>회원가입</h1>
42 <form onSubmit={onSubmit}>
43 <FormItemContainer>
44 <RootFormItem error={email}>
45 <Label>이메일</Label>
46 <Input
47 type="text"
48 {...register("email")}
49 />
50 </RootFormItem>
51 <ValidationLabel
52 validationType="email"
53 formState={email}
54 errors={errors}
55 />
56 </FormItemContainer>
57 <FormItemContainer>
58 <RootFormItem error={userName}>
59 <Label>사용자 이름</Label>
60 <Input
61 type="text"
62 {...register("userName")}
63 />
64 </RootFormItem>
65 <ValidationLabel
66 validationType="userName"
67 errors={errors}
68 />
69 </FormItemContainer>
70 <FormItemContainer>
71 <RootFormItem error={name}>
72 <Label>실명</Label>
73 <Input
74 type="text"
75 {...register("name")}
76 />
77 </RootFormItem>
78 <ValidationLabel
79 validationType="name"
80 errors={errors}
81 />
82 </FormItemContainer>
83 <FormItemContainer>
84 <RootFormItem error={password}>
85 <Label>비밀번호</Label>
86 <Input
87 type="password"
88 {...register("password")}
89 />
90 </RootFormItem>
91 <ValidationLabel
92 validationType="password"
93 errors={errors}
94 />
95 </FormItemContainer>
96 <FormItemContainer>
97 <RootFormItem error={password2}>
98 <Label>비밀번호 확인</Label>
99 <Input
100 id="password2"
101 type="password"
102 {...register("password2")}
103 />
104 </RootFormItem>
105 <ValidationLabel
106 validationType="password2"
107 errors={errors}
108 />
109 </FormItemContainer>
110 <SubmitButton
111 buttonType="block"
112 disabled={isDisabled}
113 >
114 가입하기
115 </SubmitButton>
116 </form>
117 </Wrapper>
118 );
119}
120
121export default Join;
다시 마무리
요즘 회사에서 코드를 작성할 때 이번 수업 내용과 동료의 조언을 최대한 코드에 녹여내려고 노력중이다.
- 키보드부터 두드리지 않기
- 상태 위치를 고민해보기
- 설계한 것 가지고 코드 작성하기
- 작성하면서 계산, 액션 뒤섞지 않기
예전에 어느 맨토분께 '프론트엔드에서 클린 아키텍쳐를 할 수 있는 방법'에 대해서 물은 적이 있었다.
하지만 그분은 '없다. 대신 함수형 프로그래밍 공부해봐라'라고 조언을 해주었다.
그래서 올해는 함수형 프로그래밍 공부하는데 시간을 다 쓴 것 같다.
사실 아직 함수형 프로그래밍이 뭔지는 잘 모른다. 그리고 공부를 하다보니 눈 동냥 한것을 적용해보긴 한다.
하지만 내 코드는 여전히 납득이 되는 아키텍쳐가 보이지 않는다. 나중에 시간이 지나서 내가 보더라도 납득이 가면 좋겠다.
이번 수업은 내가 그간 고민했던 부분을 꽤 많이 해소해준 수업이었다. 많은걸 보고 듣고 배우고 그리고 느낀다.
지금부터 작성하는 코드는 사람이 읽을 수 있는 코드면 좋겠다.(지금까지 작성한 코드를 사람이 못 읽는건 아니다. 샷건 치면서 읽을 수는 있다.)