[react] 회원가입 폼 만들기 #2 - 반복되는 코드 합치기 그리고 한계

dev__bokyoung·2022년 6월 13일
4
post-thumbnail

프롤로그

[그리디브] 스터디를 하며 회원가입 폼을 만들어 보기로 했었다. 리액트를 공부하며 회원가입 폼을 완성 했으나 비효율 적이라고 생각이 들었다.

기능은 잘 작동 하지만 반복되는 코드가 많았다.

팀원들이 코드리뷰를 해주었는데 같은 의견이었다. 반복되는 코드는 최대한 줄이고 하나의 컴포넌트로 합치면 어떨까라는 피드백을 받았다. 그래서 지금 보다 더 간단하게 코드를 수정해 보기로 했다.

그리고 코드를 수정하는데 있어 몇가지 고민이 필요했다.

1. 비제어 컴포넌트 vs 제어 컴포넌트

폼을 만들고 상태 관리를 하는 방식에는 두가지 방식이 있다.
비제어 컴포넌트와 제어 컴포넌트

일단 기본적으로 폼 엘리먼트는 비제어 컴포넌트이다. 하지만 폼 엘리먼트의 value를 prop으로 관리하도록 하면 그것이 제어 컴포넌트 방식이다.

비제어 컴포넌트의 플로우

  1. 사용자 입력 이벤트 발생
  2. DOM 엘리먼트의 상태를 직접 변경

제어 컴포넌트의 플로우

  1. 사용자 입력 이벤트 발생
  2. 이벤트 핸들러에서 setState(newState)
  3. 수정된 state를 바탕으로 react가 엘리먼트를 리랜더 함

비제어와 제어 컴포넌트의 사용

그렇다면 언제 비제어 컴포넌트와 제어 컴포넌트를 사용해야 할까? 🤔
리액트 공식문서에서는 대부분의 경우 폼을 구현하는데 제어 컴포넌트를 사용하는게 좋다고 한다.

기능 사용에 있어 실시간 제어 가능 & 유효성 검사가 있다면 제어 컴포넌트를 쓰자

하지만 제어 컴포넌트를 사용하는 것에 대한 대가도 분명 존재 한다.

Form 을 제어 컴포넌트로 잘 관리하기 위해서는 state 관리 위치에 대한 고민 & 잦은 리랜더링을 방지하기 위한 성능 최적화가 필요하다.

2. 상태관리를 어느 레벨에서 할 것인가?

이말인 즉슨, 어떤 범위에서 상태를 참조하고 있는가? 이다.
그리고 세가지의 경우로 나타나진다.

  1. 컴포넌트 내에서 local state 를 직접 관리 (singUp) //비효율적
  2. 부모로 부터 props 를 통해 state를 내려받기 ⇒ useRef useState ⇒ Form
  3. contextAPI 또는 reducx 등 전역 상태에서 관리하기 ( 또는 상태관리 라이브러리 등을 통해 전역 state를 받기 )

리액트를 공부하면서 고민하고 채워나갈 부분 중 하나는 상태관리를 어떻게 효율적으로 하는가? 에 대한 답을 찾는 것이라고 생각한다. 다양한 경우가 있는 그 상태에서 어떤 것이 더 나은 결정인가..?

1) 첫번째 : 컴포넌트 내에서 (local) 상태 관리를 한다면?

  • 간단한 form 의 변화들은 local 에서 상태관리를 해도 무방하다.
  • 간단한 form 들은 전역상태로 관리하게 되면 불필요한 렌더링이 발생하기 때문에 오히려 제어 컴포넌트보다 비제어 컴포넌트가 더 심플하고 성능이 좋을 수 있다.

2) 두번째 : 컴포넌트를 나누고 props 를 통해 상태관리

  • 컴포넌트를 나누고 쪼개었더니 값을 공유하기 어려워 졌다.
    예를들어, 비밀번호를 입력하고 비밀번호 확인을 하려면 값을 비교해야 한다. 그런데 컴포넌트를 나눴더니 이 값을 어떻게 공유해야 할것인가? 에 대한 물음이 생겼다.

일단 페이지를 분리한다고 했지만 부족하다 부족해😥

함수 컴포넌트로 반복되는 부분은 합쳤다.
setState 를 종류와 각 항목별로 써줬다면 지금은 종류별로만 써줘도 충분했다.

import React, { useState, useRef } from "react";

export default function Form(props) {
  const [val, setVal] = useState("");
  const [message, setMessage] = useState("");
  const [valid, setValid] = useState(false);

  const list = [
    /^[a-zA-z0-9]{4,12}$/, //id
    /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,25}$/, //password
    /^[A-Za-z0-9_]+[A-Za-z0-9]*[@]{1}[A-Za-z0-9]+[A-Za-z0-9]*[.]{1}[A-Za-z]{1,3}$/, //email
    /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/, //phone
  ];

  const onChange = (e) => {
    const val = e.target.value;
    const name = e.target.name;
    setVal(val);

    // 유효성 검사
    if (name == "id" || name == "email") {
      const RegExp = list[props.reg];
      const validTest = RegExp.test(val);
      if (!validTest) {
        setMessage(props.errorMsg);
        setValid(false);
      } else {
        setMessage(props.sucessMsg);
        setValid(true);
      }
    }

    // 갯수 검사
    if (name == "name") {
      if (val.length < 2 || val.length > 5) {
        setMessage(props.errorMsg);
        setValid(false);
      } else {
        setMessage(props.sucessMsg);
        setValid(true);
      }
    }
  };

  return (
    <div className="form-el" style={{ marginBottom: "10px" }}>
      <label htmlFor={props.htmlFor} style={{ marginRight: "10px" }}>
        {props.text}
      </label>
      <input id={props.id} name={props.name} value={val} onChange={onChange} />
      <p className="message"> {message} </p>
    </div>
  );
}

주석처리 부분을 함수 컴포넌트 페이지로 합쳤다.
그런데 생각보다 반복되지만 반복되지 않는 부분이 많았다. 비밀번호 확인같은 경우는 변경 값을 저장하고 있어줘야 했는데 함수 컴포넌트로 따로 빼주게 되면 값을 확인 해 줄 수 없었다.
전화번호 같은경우에는 '-' 생성을 위한 함수가 한번 더 필요했기에 함수 컴포넌트로 합칠 수 없었다. 그렇게 다양한 이유들로 생각보다 코드를 합치지 못했다.


import { NextPage } from "next";
import { useState, useRef, useEffect } from "react";
import Button from "../components/lib/button";
import React from "react";
import Form from "../components/lib/Form";

const Signup: NextPage = () => {
  const [password, setPassword] = React.useState("");
  const [passwordConfirm, setPasswordConfirm] = React.useState("");
  const [phone, setPhone] = React.useState("");
  const [birth, setBirth] = React.useState("");

  const [passwordMessage, setPasswordMessage] = React.useState("");
  const [passwordConfirmMessage, setPasswordConfirmMessage] =
    React.useState("");
  const [phoneMessage, setPhoneMessage] = React.useState("");

  const [isPassword, setIsPassword] = React.useState(false);
  const [isPasswordConfirm, setIsPasswordConfirm] = React.useState(false);
  const [isPhone, setIsPhone] = React.useState(false);
  const [isBirth, setIsBirth] = React.useState(false);

  const onChangePassword = (e) => {
    const currentPassword = e.target.value;
    setPassword(currentPassword);

    const passwordRegExp =
      /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,25}$/;
    if (!passwordRegExp.test(currentPassword)) {
      setPasswordMessage(
        "숫자+영문자+특수문자 조합으로 8자리 이상 입력해주세요!"
      );
      setIsPassword(false);
    } else {
      setPasswordMessage("안전한 비밀번호 입니다.");
      setIsPassword(true);
    }
  };

  const onChangePasswordConfirm = (e) => {
    const currentPasswordConfirm = e.target.value;
    setPasswordConfirm(currentPasswordConfirm);
    if (password !== currentPasswordConfirm) {
      setPasswordConfirmMessage("떼잉~ 비밀번호가 똑같지 않아요!");
      setIsPasswordConfirm(false);
    } else {
      setPasswordConfirmMessage("똑같은 비밀번호를 입력했습니다.");
      setIsPasswordConfirm(true);
    }
  };

  const onChangePhone = (getNumber) => {
    const currentPhone = getNumber;
    setPhone(currentPhone);
    const phoneRegExp = /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/;

    if (!phoneRegExp.test(currentPhone)) {
      setPhoneMessage("올바른 형식이 아닙니다!");
      setIsPhone(false);
    } else {
      setPhoneMessage("사용 가능한 번호입니다:-)");
      setIsPhone(true);
    }
  };

  const addHyphen = (e) => {
    const currentNumber = e.target.value;
    setPhone(currentNumber);
    if (currentNumber.length == 3 || currentNumber.length == 8) {
      setPhone(currentNumber + "-");
      onChangePhone(currentNumber + "-");
    } else {
      onChangePhone(currentNumber);
    }
  };

  const onChangeBirth = (e) => {
    const currentBirth = e.target.value;
    setBirth(currentBirth);
    setIsBirth(true);
  };

  return (
    <div style={{ width: "500px", margin: "0 auto" }}>
      <h3>Sign Up</h3>
      <div className="form">
        <Form
          htmlFor="id"
          id="id"
          name="id"
          text="ID"
          sucessMsg="사용가능한 아이디 입니다."
          errorMsg="4-12사이 대소문자 또는 숫자만 입력해 주세요!"
          reg="0"
        />

        <Form
          htmlFor="name"
          id="name"
          name="name"
          text="Nick Name"
          sucessMsg="사용가능한 닉네임 입니다."
          errorMsg="닉네임은 2글자 이상 5글자 이하로 입력해주세요!"
        />

        {/* <Form
          htmlFor="password"
          id="password"
          name="password"
          text="Password"
          sucessMsg="안전한 비밀번호 입니다."
          errorMsg="숫자+영문자+특수문자 조합으로 8자리 이상 입력해주세요!"
          reg="1"
        />
        <Form
          htmlFor="passwordConfirm"
          id="passwordConfirm"
          name="passwordConfirm"
          text="passwordConfirm"
          sucessMsg="떼잉~ 비밀번호가 똑같지 않아요!"
          errorMsg="똑같은 비밀번호를 입력했습니다."
        /> */}
        <div className="form-el">
          <label htmlFor="password">Password</label> <br />
          <input
            id="password"
            name="password"
            value={password}
            onChange={onChangePassword}
          />
          <p className="message">{passwordMessage}</p>
        </div>
        <div className="form-el">
          <label htmlFor="passwordConfirm">Password Confirm</label> <br />
          <input
            id="passwordConfirm"
            name="passwordConfirm"
            value={passwordConfirm}
            onChange={onChangePasswordConfirm}
          />
          <p className="message">{passwordConfirmMessage}</p>
        </div>
        <Form
          htmlFor="email"
          id="email"
          name="email"
          text="Email"
          sucessMsg="사용 가능한 이메일 입니다."
          errorMsg="이메일의 형식이 올바르지 않습니다!"
          reg="2"
        />

        <div className="form-el">
          <label htmlFor="phone">Phone</label> <br />
          <input id="phone" name="phone" value={phone} onChange={addHyphen} />
          <p className="message">{phoneMessage}</p>
        </div>

        <div className="form-el">
          <label htmlFor="birth">Birth</label> <br />
          <input
            type="date"
            id="birth"
            name="birth"
            value={birth}
            onChange={onChangeBirth}
          />
        </div>
        <br />
        <br />

        <button
          type="submit"
          disabled={
            isPassword == true &&
            isPasswordConfirm == true &&
            isPhone == true &&
            isBirth == true
              ? false
              : true
          }
        >
          Submit
        </button>
      </div>
    </div>
  );
};

export default Signup;

3) 세번째 : 전역 상태관리 contextAPI

상태를 공유하기 위해 가장 위에 contextAPI 를 써서 전역적으로 상태관리를 해 주기로 했다. 하지만 여기서도 비효율이 나타난다. 전역적으로 상태관리를 하다보니 값이 하나 바뀌게 되면 굳이 리랜더링이 되지 않아도 될 것들 까지 랜더링이 되게 된다. 이렇게 되면 성능상 낭비가 발생한다.

그래서 결론?

그래서 결국 세가지 고민과 함께 useRef 또는 useState 모두를 쓰려고 검색을 해봤지만

  1. 상태를 공유할 수 있어야 한다.
  2. 값 변경시 변경된 부분만 리랜더링이 되어야 한다.

이 둘을 충족 시킬 수 있는 답은 찾지 못했다. 약 2주동안 같은 고민으로 헤매었고, 결국 라이브러리의 도움을 받기로 한다. 그런데 정말 라이브러리 말고는 답을 찾지 못하는 걸까? 아직 나의 실력으로는 여기까지가 최선이었다는 결론이다.

그래서 스터디원의 도움을 받아 react-hook-form 라이브러리를 써보기로 한다.

에필로그

최적화된 프랙티스는 무엇일까? 약 2-3주 정도의 고민이 있었고 이것저것 시도해도 막히고 고민하는 부분은 같은 지점에서 반복되었다. 강의도 찾아서 들어보고 블로그를 봐도 해결하지 못한 부분이 있었다. 아마 여기서 아는 만큼 보인다를 증명하는 것과 같이 알아도 발굴해 내지 못하는 실력의 한계가 있지 않을까 생각한다. 다른 사람들은 어떻게 문제를 해결할까?

그래서 다음에는 팀원의 도움을 받아 그의 플로우를 온전하게 옆에서 지켜보기로 했다.

profile
개발하며 얻은 인사이트들을 공유합니다.

0개의 댓글