개발자일기/리액트 이야기

[React] Hooks를 활용해 유효성 검사를 해보자 (회원가입, 로그인)

뫙뭉 2022. 3. 15. 15:36
반응형

평소 어떤 사이트에 회원가입을 하려 할 때 우리는 유효성을 확인하는 문구를 자주 만나게 된다.

 

네이버의 회원가입 페이지에 보이는 유효성 검사

유효성 검사란 요구하는 조건을 만족하는지 확인하는 절차를 말한다.

사진을 보면 아이디의 글자수, 특수문자의 사용 가능 여부, 비밀번호와 일치하는지의 여부, 이것들 외에도 사이트에서 받아야 하는 데이터의 형식들을 제한하곤 한다.

그리고 통과하지 못한 데이터를 제출하면 오류가 발생함으로서 잘못된 데이터가 전달되지 않도록 막는 역할을 해준다.

최근 간단한 회원가입 페이지를 만들어보면서 이런 유효성 검사에 대해 리마인드 할 기회가 있었는데 이런 것들을 공유해 볼까 한다.

 

정규표현식

유효성 검사를 하는데에 있어 자주 쓰이는 것이 바로 이 정규표현식이다.

그럼 정규표현식이란 뭘까?

정규 표현식 또는 정규식은 특정한 규칙을 가진 문자열의 집합을 표현하는 데 사용하는 형식 언어이다. 정규 표현식은 많은 텍스트 편집기와 프로그래밍 언어에서 문자열의 검색과 치환을 위해 지원하고 있으며, 특히 펄과 Tcl은 언어 자체에 강력한 정규 표현식을 구현하고 있다.  - 위키백과

정규표현식은 영어로 Regular Expression 줄여서 Regex라고 부른다.

특정한 규칙을 가진 문자열을 표현하는 것이 바로 이 정규표현식.

메일 형식의 아이디를 만들라는 사이트에서 아무거나 치고 만들기를 눌러보면 

'메일 형식이 아닙니다' 라고 뜨고, @를 포함시키면 그제서야 만들기가 가능해 지는 경험을 해본 적이 있을 것이다.

정규표현식을 통해 문자열과 특수문자 @를 꼭 포함시키도록 지정을 해놓게 된다.

 

정규표현식에 관한 글은 MDN 사이트를 참고하길 바란다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Regular_Expressions

 

정규 표현식 - JavaScript | MDN

정규 표현식, 또는 정규식은 문자열에서 특정 문자 조합을 찾기 위한 패턴입니다. JavaScript에서는 정규 표현식도 객체로서, RegExp의 exec()와 test() 메서드를 사용할 수 있습니다. String의 match(), matchA

developer.mozilla.org

아래 링크는 이번에 알게 된 정규표현식 해석기 사이트이다. 정규표현식을 입력하면 그림으로 알기 쉽게 해석해준다.

https://regexper.com/

 

Regexper

 

regexper.com

 

 

자, 이제 정규표현식을 이용해 아이디와 비밀번호에 필요한 조건을 만들어보자.

const USER_REGEX = /^[a-zA-Z][a-zA-Z0-9-_]{3,23}$/;
const PWD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%]).{8,24}$/;

입력받은 아이디와 비밀번호의 조건 테스트를 위해 따로 정규표현식을 변수에 저장해 놓는다.

 

먼저 USER_REGEX를 해석해보자면

첫글자는 소문자, 대문자 알파벳이어야하며

나머지 글자는 소문자, 대문자, 숫자, 밑줄이 가능하고 3~23자이다. 따라서 총 4~24글자.

 

PWD_REGEX를 해석해보면 소문자, 대문자, 숫자, 특수문자 !@#$%가 꼭 들어있고 8~24글자.

라는 뜻이다.

 

이렇게 변수에 저장해 놓는 이유는 나중에 테스트 할 때의 편리함 때문. 조건을 변경시킬 때는 변수의 내용만 변경하면 쉽게 관리할 수 있다.

 

본격적인 코드 짜보기

해당 프로젝트에서 사용하는 아이콘은 fontawsome을 사용하였다.

fontawsome을 사용하실 분들은 설치해서 사용하면 되겠다.

$ npm install @fortawesome/fontawesome-svg-core
$ npm install @fortawesome/free-solid-svg-icons
$ npm install @fortawesome/react-fontawesome

state 설정

우선 state를 만든다.

우리가 필요한 state는 크게 3가지 종류이다.

- 아이디

- 비밀번호

- 비밀번호 확인

 

여기에서 다시 3가지 용도로 분류한다.

각각

- 내용

- 유효성

- Focus 여부

 

내용은 말 그대로 해당 내용(아이디, 비밀번호, 확인)의 상태.

유효성은 우리가 설정한 정규표현식과의 일치 여부.

Focus 여부는 input의 Focus 여부인데 Focus 되었을 때 가능한 문자에 대한 안내문구를 띄우기 위해서이다.

 

마지막으로 회원가입 성공 여부.

회원가입에 성공하면 form 대신에 축하 문구를 띄워 성공한 것을 사용자에게 알려줄 때 사용할 것이다.

 

현재까지의 코드

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

const USER_REGEX = /^[a-zA-Z][a-zA-Z0-9-_]{3,23}$/;
const PWD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%]).{8,24}$/;
const REGISTER_URL = './register'

const Register = () => {
  const userRef = useRef();
  const errRef = useRef();

  const [user, setUser] = useState("");
  const [validName, setValidName] = useState(false);
  const [userFocus, setUserFocus] = useState(false);

  const [pwd, setPwd] = useState("");
  const [validPwd, setValidPwd] = useState(false);
  const [pwdFocus, setPwdFocus] = useState(false);

  const [matchPwd, setMatchPwd] = useState("");
  const [validMatch, setValidMatch] = useState(false);
  const [matchFocus, setMatchFocus] = useState(false);
  
  const [success, setSuccess] = useState(false);
  
  return(
    <></>
  )
}

 

유효성 검사

이제 useEffect를 통한 유효성 검사를 진행한다.

유효성 검사 통과 여부를 validName, validPwd, validMatch에 boolean 타입으로 저장되고

각각 결과에 따라 통과되었는지 여부를 사용자에게 나타낼 것이다.

const Register = () => {
  
  // state 생략

  useEffect(() => {
    const result = USER_REGEX.test(user);
    setValidName(result);
  }, [user]);

  useEffect(() => {
    const result = PWD_REGEX.test(pwd);
    setValidPwd(result);
    const match = pwd === matchPwd;
    setValidMatch(match);
  }, [pwd, matchPwd]);

  return (
    <></>
  );
};

export default Register;

test 메소드를 통해 설정한 정규표현식과의 통과여부를 setState한다.

여기서 useEffect를 통해 아이디, 비밀번호, 비밀번호 확인이 변경될 때마다 검사하도록 코드를 짜준다.

이것으로 준비는 얼추 마무리가 되었다.

 

조건부로 render 하기

그러면 state에 따라 사용자에게 어떻게 나타날 지에 대해 코드를 짜보자.

validName에 따라 아이콘을 변경시켜(초록색 체크, 빨간색 x) 사용자에게 현재 상태를 전달한다.

onFocus와 onBlur 이벤트를 통해 현재 input의 Focus여부를 핸들링하고,

Focus여부와 글자가 적히게 되면 해당 input의 설명을 사용자에게 보여준다.

import { useRef, useState, useEffect } from "react";
import {   faCheck, faTimes, faInfoCircle,} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";

const Register = () => {

// 상단 코드 생략

return (
    <>
      <h1>회원가입</h1>
      <form onSubmit={handleSubmit}>
        <label htmlFor="username">
          아이디
          <span className={validName ? "valid" : "hide"}>
            <FontAwesomeIcon icon={faCheck} color="green" />
          </span>
          <span className={validName || !user ? "hide" : "invalid"}>
            <FontAwesomeIcon icon={faTimes} color="red" />
          </span>
        </label>
        <input
          type="text"
          id="username"
          autoComplete="off"
          onChange={(e) => {
            setUser(e.target.value);
          }}
          required
          onFocus={() => {
            setUserFocus(true);
          }}
          onBlur={() => {
            setUserFocus(false);
          }}
        />
        <p
          id="uidnote"
          className={
            userFocus && user && !validName ? "instructions" : "offscreen"
          }
        >
          <FontAwesomeIcon icon={faInfoCircle} />
          글자수를 4~24로 맞춰주세요. <br />
          영어 알파벳으로 시작해야합니다. <br />
          가능한 문자 : 알파벳, 숫자, _ , -
        </p>
    </>
  );

}

같은 방식으로 비밀번호와 비밀번호 확인도 처리를 하면 된다.

// ...

const Register = () => {
  
  // state 생략

  return (
    <>
      {success ? (
        <section>
          <h1>회원가입을 축하합니다!</h1>
          <p>
            <a href="#">로그인하기</a>
          </p>
        </section>
      ) : (
        <section>
          <p
            ref={errRef}
            className={errMsg ? "errmsg" : "offscreen"}
          >
            {errMsg}
          </p>
          <h1>회원가입</h1>
          <form onSubmit={handleSubmit}>
            <label htmlFor="username">
              아이디
              <span className={validName ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validName || !user ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="text"
              id="username"
              ref={userRef}
              autoComplete="off"
              onChange={(e) => {
                setUser(e.target.value);
              }}
              required
              onFocus={() => {
                setUserFocus(true);
              }}
              onBlur={() => {
                setUserFocus(false);
              }}
            />
            <p
              id="uidnote"
              className={
                userFocus && user && !validName ? "instructions" : "offscreen"
              }
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              글자수를 4~24로 맞춰주세요. <br />
              영어 알파벳으로 시작해야합니다. <br />
              가능한 문자 : 알파벳, 숫자, _ , -
            </p>

            <label htmlFor="password">
              비밀번호
              <span className={validPwd ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validPwd || !pwd ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="password"
              id="password"
              onChange={(e) => {
                setPwd(e.target.value);
              }}
              required
              onFocus={() => {
                setPwdFocus(true);
              }}
              onBlur={() => {
                setPwdFocus(false);
              }}
            />
            <p
              id="pwdnote"
              className={pwdFocus && !validPwd ? "instructions" : "offscreen"}
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              글자수를 8~24로 맞춰주세요
              <br />
              영어 대문자, 소문자, 숫자, 특수문자를 반드시 포함시켜야 합니다.
              <br />
              가능한 특수문자 :
              <span aria-label="exclamation mark">!</span>
              <span aria-label="at symbol">@</span>
              <span aria-label="hashtag">#</span>
              <span aria-label="dollor sign">$</span>
              <span aria-label="percent">%</span>
            </p>

            <label htmlFor="confirm_pwd">
              비밀번호 확인
              <span className={validMatch && matchPwd ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validMatch || !matchPwd ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="password"
              id="confirm_pwd"
              onChange={(e) => {
                setMatchPwd(e.target.value);
              }}
              required
              onFocus={() => {
                setMatchFocus(true);
              }}
              onBlur={() => {
                setMatchFocus(false);
              }}
            />
            <p
              id="confirmnote"
              className={matchFocus && !validMatch ? "instructions" : "offscreen"}
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              비밀번호와 일치해야합니다.
            </p>

            <button
              disabled={!validName || !validPwd || !validMatch ? true : false}
            >
              Sign Up
            </button>
          </form>

          <p>
            이미 회원가입 하셨나요?            
            <span className="line">
              {/* 라우터 링크 */}
              <a href="#">로그인하기</a>
            </span>
          </p>
        </section>
      )}
    </>
  );
};

export default Register;

서버에 전송, 에러처리 하기

지금은 프론트만 처리했기 때문에 실제 동작하게 하려면 백엔드가 필수적이다. 따라서 해당 코드만으로는 프론트에서의 동작만 할 것이다.

하지만 서버에 전송하고 에러를 어떻게 처리할지에 대해 공부해보자.

 

우선 form 태그에서는 onSubmit 시 handleSubmit 함수를 통해 에러와 서버 통신을 핸들링한다.

 

우선 서버에 정보를 전송하기 전에 혹시 모를 버그를 대비해서 유효성 검사를 한번 더 진행한다.

서버에 전송되면 올바르지 않은 정보가 저장될 수 있기에 그전에 한번 더 핸들링하여 에러를 잡아낸다.

유효성 검사를 한번 더 통과했으면 서버에 전송한다. 나는 axios의 post요청으로 서버에 요청하였다.

 

다음은 에러처리.

회원가입에서의 에러를 3가지로 분류하였다.

- 아이디가 이미 존재하는 경우

- 서버가 이상한 경우

- 그 외에 회원가입 실패

 

백엔드에서 에러가 있다고 보내주면 에러 코드에 따라 위의 상태를 에러메시지로 사용자에게 보여준다. 그리하여 상황에 따라 사용자가 대응 할 수 있도록 유도한다.

 

그러기 위해 errMsg 변수를 생성하여 상황별 에러메시지를 표시할 필요가 있다.

백엔드에서 보내준 코드에 따라 setErrMsg를 통해 에러메시지를 갱신한다.

만약 errMsg가 존재한다면 회원가입 상단에 에러의 내용을 사용자에게 보여준다.

import { useRef, useState, useEffect } from "react";
import {   faCheck, faTimes, faInfoCircle,} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import axios from "./axios";

const USER_REGEX = /^[a-zA-Z][a-zA-Z0-9-_]{3,23}$/;
const PWD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%]).{8,24}$/;
const REGISTER_URL = './register'

const Register = () => {
  
  // state 생략

  const [errMsg, setErrMsg] = useState("");

  const [success, setSuccess] = useState(false);

  useEffect(() => {
    setErrMsg("");
  }, [user, pwd, matchPwd]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const v1 = USER_REGEX.test(user);
    const v2 = PWD_REGEX.test(pwd);

    if (!v1 || !v2) {
      setErrMsg("적절치 않은 아이디, 비밀번호");
      return;
    }

    try{
      const response = await axios.post(REGISTER_URL,
        JSON.stringify({user, pwd}),
        {
          headers: {'Content-Type': 'application/json'},
          withCredentials: true
        }
      );
      console.log(response.data);
      console.log(response.accessToken);
      console.log(JSON.stringify(response))
      setSuccess(true);
      
    } catch (err) {
      if(!err?.response) {
        setErrMsg('서버의 응답이 없습니다');
      } else if(err.response?.statut === 409){
        setErrMsg('이미 아이디가 존재합니다');
      } else{
        setErrMsg('회원가입 실패, 잠시 후 다시 시도하십시오.')
      }
      errRef.current.focus();
    }
  };

  return (
    <>
        <section>
          <p
            ref={errRef}
            className={errMsg ? "errmsg" : "offscreen"}
          >
            {errMsg}
          </p>
          <h1>회원가입</h1>
          <form onSubmit={handleSubmit}>
            
            // 코드 생략

            <button
              disabled={!validName || !validPwd || !validMatch ? true : false}
            >
              Sign Up
            </button>
          </form>

          <p>
            이미 회원가입 하셨나요?            
            <span className="line">
              {/* 라우터 링크 */}
              <a href="#">로그인하기</a>
            </span>
          </p>
        </section>
    </>
  );
};

export default Register;

 

편의성 개선

간단한 편의성 개선을 해보자.

페이지에 들어서면 아이디 창에 포커스되어 바로 정보를 입력할 수 있게 하면 좋지 않을까 생각했다.

useRef를 이용해서 처리해보자.

 

useEffect의 depth에 빈 배열을 넣게되면 페이지가 처음 랜더링 되었을 때 함수가 처리된다.

input에 ref를 달아주고 useEffect 안에 다음과 같이 처리해주면 첫 랜더링 후 포커스가 되게 할 수 있다.

const Register = () => {
  const userRef = useRef();
  
  useEffect(() => {
    // 페이지가 렌더링 되었을 때 userRef에 포커스 되도록
    userRef.current.focus();
  }, []);

  return (
    <>
        // ... 
        
            <input
              type="text"
              id="username"
              ref={userRef}
              autoComplete="off"
              onChange={(e) => {
                setUser(e.target.value);
              }}
              required
              onFocus={() => {
                setUserFocus(true);
              }}
              onBlur={() => {
                setUserFocus(false);
              }}
            />
            
        // ...
            
        </section>
    </>
  );
};

export default Register;

 

아래는 최종적으로 완성된 모습과 코드이다.

success가 true일 경우 회원가입 완료 메시지가 나오는 부분을 추가하였다.

이제 어떤 창이 잘못되었는지 한눈에 보이시나요?

 

더보기
import { useRef, useState, useEffect } from "react";
import {   faCheck, faTimes, faInfoCircle,} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import axios from "./axios";

const USER_REGEX = /^[a-zA-Z][a-zA-Z0-9-_]{3,23}$/;
const PWD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%]).{8,24}$/;
const REGISTER_URL = './register'

const Register = () => {
  const userRef = useRef();
  const errRef = useRef();

  const [user, setUser] = useState("");
  const [validName, setValidName] = useState(false);
  const [userFocus, setUserFocus] = useState(false);

  const [pwd, setPwd] = useState("");
  const [validPwd, setValidPwd] = useState(false);
  const [pwdFocus, setPwdFocus] = useState(false);

  const [matchPwd, setMatchPwd] = useState("");
  const [validMatch, setValidMatch] = useState(false);
  const [matchFocus, setMatchFocus] = useState(false);

  const [errMsg, setErrMsg] = useState("");

  const [success, setSuccess] = useState(false);

  useEffect(() => {
    // 페이지가 렌더링 되었을 때 userRef에 포커스 되도록
    userRef.current.focus();
  }, []);

  useEffect(() => {
    const result = USER_REGEX.test(user);
    setValidName(result);
  }, [user]);

  useEffect(() => {
    const result = PWD_REGEX.test(pwd);
    setValidPwd(result);
    const match = pwd === matchPwd;
    setValidMatch(match);
  }, [pwd, matchPwd]);

  useEffect(() => {
    setErrMsg("");
  }, [user, pwd, matchPwd]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const v1 = USER_REGEX.test(user);
    const v2 = PWD_REGEX.test(pwd);

    if (!v1 || !v2) {
      setErrMsg("Invalid Entry");
      return;
    }

    console.log(user, pwd);

    try{
      const response = await axios.post(REGISTER_URL,
        JSON.stringify({user, pwd}),
        {
          headers: {'Content-Type': 'application/json'},
          withCredentials: true
        }
      );
      console.log(response.data);
      console.log(response.accessToken);
      console.log(JSON.stringify(response))
      setSuccess(true);
      // clear input fields
    } catch (err) {
      if(!err?.response) {
        setErrMsg('No Server Response');
      } else if(err.response?.statut === 409){
        setErrMsg('Username Taken');
      } else{
        setErrMsg('Registration Failed')
      }
      errRef.current.focus();
    }
  };

  return (
    <>
      {success ? (
        <section>
          <h1>회원가입을 축하합니다!</h1>
          <p>
            <a href="#">로그인하기</a>
          </p>
        </section>
      ) : (
        <section>
          <p
            ref={errRef}
            className={errMsg ? "errmsg" : "offscreen"}
          >
            {errMsg}
          </p>
          <h1>회원가입</h1>
          <form onSubmit={handleSubmit}>
            <label htmlFor="username">
              아이디
              <span className={validName ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validName || !user ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="text"
              id="username"
              ref={userRef}
              autoComplete="off"
              onChange={(e) => {
                setUser(e.target.value);
              }}
              required
              onFocus={() => {
                setUserFocus(true);
              }}
              onBlur={() => {
                setUserFocus(false);
              }}
            />
            <p
              id="uidnote"
              className={
                userFocus && user && !validName ? "instructions" : "offscreen"
              }
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              글자수를 4~24로 맞춰주세요. <br />
              영어 알파벳으로 시작해야합니다. <br />
              가능한 문자 : 알파벳, 숫자, _ , -
            </p>

            <label htmlFor="password">
              비밀번호
              <span className={validPwd ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validPwd || !pwd ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="password"
              id="password"
              onChange={(e) => {
                setPwd(e.target.value);
              }}
              required
              onFocus={() => {
                setPwdFocus(true);
              }}
              onBlur={() => {
                setPwdFocus(false);
              }}
            />
            <p
              id="pwdnote"
              className={pwdFocus && !validPwd ? "instructions" : "offscreen"}
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              글자수를 8~24로 맞춰주세요
              <br />
              영어 대문자, 소문자, 숫자, 특수문자를 반드시 포함시켜야 합니다.
              <br />
              가능한 특수문자 :
              <span aria-label="exclamation mark">!</span>
              <span aria-label="at symbol">@</span>
              <span aria-label="hashtag">#</span>
              <span aria-label="dollor sign">$</span>
              <span aria-label="percent">%</span>
            </p>

            <label htmlFor="confirm_pwd">
              비밀번호 확인
              <span className={validMatch && matchPwd ? "valid" : "hide"}>
                <FontAwesomeIcon icon={faCheck} color="green" />
              </span>
              <span className={validMatch || !matchPwd ? "hide" : "invalid"}>
                <FontAwesomeIcon icon={faTimes} color="red" />
              </span>
            </label>
            <input
              type="password"
              id="confirm_pwd"
              onChange={(e) => {
                setMatchPwd(e.target.value);
              }}
              required
              onFocus={() => {
                setMatchFocus(true);
              }}
              onBlur={() => {
                setMatchFocus(false);
              }}
            />
            <p
              id="confirmnote"
              className={matchFocus && !validMatch ? "instructions" : "offscreen"}
            >
              <FontAwesomeIcon icon={faInfoCircle} />
              비밀번호와 일치해야합니다.
            </p>

            <button
              disabled={!validName || !validPwd || !validMatch ? true : false}
            >
              Sign Up
            </button>
          </form>

          <p>
            이미 회원가입 하셨나요?            
            <span className="line">
              {/* 라우터 링크 */}
              <a href="#">로그인하기</a>
            </span>
          </p>
        </section>
      )}
    </>
  );
};

export default Register;

 

마치며..

유효성 검사에 대해 알아보았다. 이번 글은 상당히 길이가 길어졌는데 중간에 한번 날라가서 굉장히 당황했었다는...

이번 튜토리얼을 진행하면서 여러가지를 배웠다.

state를 어떤식으로 활용하고 상황에 맞는 랜더링, 에러처리로 사용자에게 에러 상황 전달하는 것, 마지막으로 간단한 편의성 개선까지 여러가지에 대해 깊이 고민해 볼 수 있는 좋고 간단한 프로젝트였던 것 같다.

많은 분들에게 도움이 되었으면 한다.

반응형