리알못 React: 6. Forms

Table of Content

이 글은 Code with Mosh의 Mastering React 과정을 공부하며 정리한 글입니다. 리알못이라 이상하거나 틀린 내용이 있을 것입니다…

Forms

여기서는 Bootstrap Forms를 사용하여 Username과 Password를 입력받아 로그인하는 컴포넌트를 만듭니다. 그리고 Joi를 사용하여 입력값을 검증하는 방법을 알아봅니다.

Bootstrap을 이용한 기본적인 로그인 컴포넌트 만들기

React 프로젝트가 위치한 경로에서 npm i bootstrap 명령어로 Bootstrap 패키지를 설치한 후 index.js 파일에 import 'bootstrap/dist/css/bootstrap.css';를 추가해줍니다.

Bootstrap Forms에 대한 자세한 설명은 https://getbootstrap.com/docs/4.3/components/forms 을 참고하시기 바랍니다. 여기서는 Username과 Password를 입력받는 부분과 로그인 버튼을 배치하는 컴포넌트를 아래와 같이 간단히 만들어보겠습니다.

/***** App.js *****/

import React from 'react';
import LoginForm from './components/LoginForm.js';
import './App.css';

const App = () => {
  return <LoginForm />
};

export default App;
/***** ./components/LoginForm.jsx *****/

import React from "react";

const LoginForm = () => {
  const handleSubmit = (e) => {
    e.preventDefault(); // 기본 동작 방지
  }

  return (
    <div>
      <h1>Login</h1>
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="username">Username</label>{" "} {/* 이 label 태그는 아래 input 태그를 참조함  */}
          <input id="username" type="text" className="form-control" />
        </div>
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input id="password" type="password" className="form-control" />
        </div>
        <button className="btn btn-primary">Login</button>
      </form>
    </div>
  );
};

export default LoginForm;

for는 Javascript의 예약어이므로 React에서는 html 태그의 for 속성을 htmlfor로 대체하여 사용합니다. htmlfor는 다른 태그의 id 값을 참조합니다.

form 태그에서 제출(onSubmit) 이벤트가 발생하면 기본적으로 페이지를 새로 고칩니다. onSubmit 이벤트 처리기에서 이벤트 객체에 정의된 preventDefault() 함수를 사용하면 이 기본동작을 막을 수 있습니다.

<form onSubmit={handleSubmit}> {/* form 태그의 onSubmit 이벤트 처리기 지정 */}
handleSubmit = (e) => {
  e.preventDefault(); // 기본 동작 방지
}

제어 요소(Controlled Elements)

input 태그에 입력된 값을 state와 연결하여 사용할 수 있습니다.

  1. input 태그에 입력된 값을 연결할 state와 input 태그 값이 변경될 때마다 이를 state에 저장할 이벤트 처리 함수를 생성합니다.
import React, { useState } from "react";

const LoginForm = () => {
  // state 생성
  const [userInfo, setUserInfo] = useState({
    username: '',
    password: '',
  });

  // input 태그 변경 이벤트 처리 함수
  const handleChange = (e) => {
    const data = { ...userInfo };
    data.username = e.currentTarget.value; // input 태그에 입력된 값 지정
    setUserInfo(data);
    console.log(userInfo);
  }

  const handleSubmit = (e) => {
    e.preventDefault(); // 기본 동작 방지
  }

  // 생략...
}
  1. input 태그에 value 속성 및 onChange 이벤트 처리기를 명시합니다. 참고로 onChange 이벤트를 처리하지 않으면 경고가 발생합니다.
<form onSubmit={handleSubmit}>
   <input
     onChange={handleChange} {/* 제어 요소 값이 바뀔 때 호출할 이벤트 처리기 */}
     id="username"
     type="text"
     className="form-control"
   />
   {/* 생략... */}
</form>

여러 개의 onChange 이벤트 한 번에 다루기

Username과 Password를 입력받는 로그인 페이지를 만든다면 input 태그를 두 개 배치한 후 각각의 이벤트 처리기를 만들어 처리할 수 있습니다. 그러나 하나의 이벤트 처리기를 만들어 처리하는 것이 코드의 중복을 방지할 수 있어 더 효율적입니다.

Username과 Password를 입력받는 각각의 Input 태그에서 발생한 이벤트를 하나의 이벤트 처리기로 처리하는 방법에 대해 알아봅시다.

  1. input 태그에 입력된 값을 연결할 state와 input 태그 값이 변경될 때마다 이를 state에 저장할 이벤트 처리 함수를 생성합니다. 이 때 State에 접근 시 대괄호 []를 사용하여 어떤 객체에 값을 지정할 지 구분해줍니다.
import React, { useState } from "react";

const LoginForm = () => {
  // state 생성
  const [userInfo, setUserInfo] = useState({
    username: '',
    password: '',
  });

  // input 태그 변경 이벤트 처리 함수
  const handleChange = (e) => {
    // [e.target.id] <- input 태그의 id 속성을 가리킴.
    // e.target.value <- 이벤트를 발생시킨 객체의 값을 가리킴
    // 좀 헷갈리긴 한데... "e.target.id": e.target.value 로 코딩할 수 없으니 []를 사용(?)
    setUserInfo({ ...userInfo, [e.target.id]: e.target.value });
    console.log(userInfo);
  }

  const handleSubmit = (e) => {
    e.preventDefault(); // 기본 동작 방지
  }

  // 생략...
}
  1. 입력 폼을 만듭니다. 여기서 두 input 태그의 이벤트 처리기는 handleChange()입니다.
<form onSubmit={handleSubmit}>
  <div className="form-group">
    <label htmlFor="username">Username</label>{" "} {/* 이 label 태그는 아래 input 태그를 참조함  */}
    <input
      onChange={handleChange}
      id="username" 
      type="text" 
      className="form-control" />
  </div>
  <div className="form-group">
    <label htmlFor="password">Password</label>
    <input
      onChange={handleChange}  
      id="password" 
      type="password" 
      className="form-control" />
  </div>
  <button className="btn btn-primary">Login</button>
</form>

일반적인 오류

제어 요소(Props)에선 null 또는 undefined를 사용할 수 없습니다. 사용하게 되면 처음엔 에러가 나지 않지만 제어하려고 시도한 후 콘솔 창에 에러가 발생할 수 있습니다.

Joi 라이브러리를 사용한 검증(Validation)

Username과 Password를 입력받는 경우 값이 입력되지 않아선 안 됩니다. 따라서 Username과 Password를 입력하는 도중 공백이 있으면 사용자에게 알리고, 검증이 안 된 경우 Login 버튼을 비활성화할 수 있습니다.

이 때 Joi 라이브러리를 사용하면 입력 값에 대한 유효성 검사를 편리하게 할 수 있습니다. 예를 들어 아이디는 몇 자리까지 허용할지, 숫자는 몇 까지 입력 가능한지 등에 대한 제약을 걸 수 있습니다. Joi 라이브러리에 대한 자세한 설명은 https://gumpcha.github.io/blog/joi-overview 를 참고하기 바랍니다.

  • Joi 라이브러리의 사용 예
schema = {
    // _id: null을 허용하는 문자열
    _id: Joi.string(),

    // title: null을 허용하지 않는 문자열
    title: Joi.string()
      .required()
      .label("Title"),

    // number: 0~100 사이의 null을 허용하지 않는 정수
    number: Joi.number()
      .required()
      .min(0)
      .max(100)
      .label("Number in Stock"),
};

기본적인 사용 방법

  1. React 프로젝트가 위치한 경로에서 npm i joi-browser 명령어로 joi-browser 패키지를 설치합니다.

  2. 컴포넌트 내에 제약조건을 명시할 객체를 만들어줍니다.

// Joi에서 사용하는 schema는 변경되는 값이 아니므로 state에 지정할 필요 없음
const schema = {
  // 객체 이름이 state 객체와 다르면 런타임 오류 발생
  username: Joi.string().required(), // 제약조건: 문자열 & 값이 반드시 입력되어야 함
  password: Joi.string().required(), 
}
  1. Joi.validate() 함수를 사용하여 검증을 시도합니다.
// 첫번째 매개변수: 검증할 객체
// 두번째 매개변수: 제약조건을 명시한 객체
// abortEarly: 정의된 key 중 에러가 발생하면 더 이상 진행하지 않음. 기본값은 true.
const result = Joi.validate(this.state.data, this.schema, { abortEarly: false }); 
console.log(result);

Joi 라이브러리를 사용하여 입력 값 변경 시 유효성 검사

Username 및 Password 입력 값이 바뀔 때마다 Joi 라이브러리를 사용하여 입력 값이 올바른지 검증하도록 합니다.

아래 예제는 Username과 Password가 빈 값이면 에러 정보를 state에 저장하는 코드입니다.

import React, { useState } from "react";
import Joi, { validate } from "joi-browser";

const LoginForm = () => {
  const userSchema = {
    username: Joi.string().required(),
    password: Joi.string().required(),
  };

  const [userInfo, setUserInfo] = useState({
    username: "",
    password: "",
  });

  const handleSubmit = (e) => {
    e.preventDefault(); // 기본 동작 방지
  };

  const handleChange = (e) => {
    // e.target.id: 변경이 일어난 input 태그의 id
    // e.target.value: 변경이 일어난 input 태그의 값
    const { id, value } = e.target;
    const errors = { ...userInfo.errors };

    /* 입력받은 값 유효성 검증 */
    const obj = { [id]: value }; // 입력받은 값에 
    const schema = { [id]: userSchema[id] }; // Joi 스키마를 적용하여
    const { error } = validate(obj, schema); // 유효성 검증

    if(error) errors[id] = error;
    else delete errors[id];

    /* 입력받은 username 및 password를 state에 저장 */
    const data = userInfo;
    data[id] = value; // 점(.) 표기법을 대괄호([]) 표기법으로 사용
    setUserInfo({ ...data, errors });
  };

  return (
    <div>
      <h1>Login</h1>
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="username">Username</label>{" "}
          {/* 이 label 태그는 아래 input 태그를 참조함  */}
          <input
            onChange={handleChange}
            id="username"
            type="text"
            className="form-control"
          />
        </div>
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            onChange={handleChange}
            id="password"
            type="password"
            className="form-control"
          />
        </div>
        <button className="btn btn-primary">Login</button>
      </form>
    </div>
  );
};

export default LoginForm;

만약 Joi 라이브러리를 사용하지 않았다면 다음과 같이 if문을 쭉 나열하여 코딩해야 했을 것입니다.

if (name === 'username') {
    if (value.trim() === '') 
      return 'Username is required.';
}
if (name === 'password') {
    if (value.trim() === '') 
      return 'Password is required.';
}

Joi 라이브러리를 사용하여 Login 버튼 비활성화

Username과 Password 입력 값이 유효하지 않을 때 Login 버튼을 비활성화합니다.

  1. form 태그 내에 있는 Login button의 disabled 속성에 함수를 지정해줍니다. 이 함수는 입력 값이 유효하면 null을, 유효하지 않으면 에러 정보를 반환하도록 합니다.
<button
  disabled={ButtonValidate()} // Username과 Password의 입력 값이 유효하지 않을 때 버튼 비활성화
  className="btn btn-primary">Login
</button>
  1. 위에서 지정한 함수를 선언해줍니다.
const buttonValidate = () => {
  const options = { abortEarly: false, allowUnknown:true }; // allowUnknown: 스키마에 정의되지 않은 객체는 유효성을 검증하지 않고 무조건 허용
  const { error } = Joi.validate(userInfo, userSchema, options);

  // 에러 미발생 시 null 반환
  if (!error) return null;

  // 에러 발생 시 에러 정보 반환
  const errors = {};
  for (let item of error.details) {
  errors[item.path[0]] = item.message;
  }
  return errors;
}

Joi 라이브러리를 사용하지 않았다면 다음과 같이 코딩해야 했을 것입니다.

const errors = {};

const { data } = this.state;
if (data.username.trim() === '') // trim(): 좌우 공백 제거
  errors.username = 'Username is required.'
if (data.password.trim() === '')
  errors.password = 'Password is required.'

return Object.keys(errors).length === 0 ? null : errors; // Object.keys: 배열의 키 값을 반환

정리

전체 코드

import React, { useState } from "react";
import Joi, { validate } from "joi-browser";

const LoginForm = () => {
  const userSchema = {
    username: Joi.string().required(),
    password: Joi.string().required(),
  };

  const [userInfo, setUserInfo] = useState({
    username: "",
    password: "",
  });

  const handleSubmit = (e) => {
    e.preventDefault(); // 기본 동작 방지
  };

  const handleChange = (e) => {
    // e.target.id: 변경이 일어난 input 태그의 id
    // e.target.value: 변경이 일어난 input 태그의 값
    const { id, value } = e.target;
    const errors = { ...userInfo.errors };

    /* 입력받은 값 유효성 검증 */
    const obj = { [id]: value }; // 입력받은 값에 
    const schema = { [id]: userSchema[id] }; // Joi 스키마를 적용하여
    const { error } = validate(obj, schema); // 유효성 검증

    if(error) errors[id] = error;
    else delete errors[id];

    /* 입력받은 username 및 password를 state에 저장 */
    const data = userInfo;
    data[id] = value; // 점(.) 표기법을 대괄호([]) 표기법으로 사용
    setUserInfo({ ...data, errors });
  };

  const buttonValidate = () => {
    const options = { abortEarly: false, allowUnknown:true };
    const { error } = Joi.validate(userInfo, userSchema, options);

    // 에러 미발생 시 null 반환
    if (!error) return null;

    // 에러 발생 시 에러 정보 반환
    const errors = {};
    for (let item of error.details) {
      errors[item.path[0]] = item.message;
    }
    return errors;
  }

  const handleClick = (e) => {
    e.preventDefault();
    const validUsername = 'ingyeo';
    const validPassword = '1234';

    if(validUsername === userInfo.username && validPassword === userInfo.password)
      alert('로그인 성공!')
    else 
      alert('로그인 실패...');
  }

  return (
    <div>
      <h1>Login</h1>
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="username">Username</label>{" "}
          {/* 이 label 태그는 아래 input 태그를 참조함  */}
          <input
            onChange={handleChange}
            id="username"
            type="text"
            className="form-control"
          />
        </div>
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            onChange={handleChange}
            id="password"
            type="password"
            className="form-control"
          />
        </div>
        <button 
          disabled={buttonValidate()}
          onClick={handleClick}
          className="btn btn-primary">Login</button>
      </form>
    </div>
  );
};

export default LoginForm;

댓글 남기기