위코드 1차 팀프로젝트를 진행하며 정리한 내용입니다.
이메일, 비밀번호, 이름, 성별, 휴대폰, 생년월일, 개인정보 유효기간을 입력하는 회원가입 페이지입니다. 버튼 하나로 가입하는 소셜로그인을 생각한다면 사용자에게는 꽤 번거로운 UI 입니다. 그래도 연습이라 생각해 간소화하지 않고 그대로 작업했습니다. 대신 모든 창을 다 입력하고 버튼을 눌렀는 데 재입력을 요청하지 않도록 주요 인풋값에 유효성 검사를 넣어 안내를 했고, 필수 값의 입력 및 유효성 검사가 끝난 후에 가입하기 버튼을 활성화 했습니다.
form 태그의 자식요소로 input 태그를 사용할 경우 onChange 에 하나의 handleInput 함수를 연결해 입력값을 가져올 수 있습니다. 각 Input 에는 name 을 입력해야 하고 ('name : 입력값' 형태로 저장가 위함) 라디오 버튼의 경우 name 을 통일해야 하나의 라디오만 선택되고 선택한 버튼의 value 속성값을 가져옵니다.
// 이메일, 비밀번호 입력
<form>
<input
onChange={handleInput}
className="userInputEmail input"
name="email"
type="text"
placeholder="이메일"
autoComplete="username"
/>
<input
onChange={handleInput}
className="userInputPw input"
name="pw"
type="password"
placeholder="비밀번호"
autoComplete="current-password"
/>
..
</form>
// 라디오 버튼 입력, label 태그안에 input 태그와 span 태그를 담아서 사용
<form>
<label className="userMale label">
<input
onChange={handleInput}
className="radio"
name="gender"
type="radio"
value="man"
/>
<span className="text">남자</span>
</label>
<label className="userFemale label">
<input
onChange={handleInput}
className="radio"
name="gender"
type="radio"
value="woman"
/>
<span className="text">여자</span>
</label>
</form>
이제 최상단에서 state 로 input 값을 선언하고 handleInput 함수로 인풋값을 모아서 state 에 담아줍니다.
const [userInput, setUserInput] = useState({
email: '',
pw: '',
pwCheck: '',
name: '',
gender: '',
phoneNum: '',
year: '',
month: '',
day: '',
time: '',
});
const { email, pw, pwCheck, name, gender, phoneNum, year, month, day, time } = userInput; // 구조분해 할당하기
const handleInput = e => {
const { name, value } = e.target;
setUserInput({ ...userInput, [name]: value });
};
이메일과 비밀번호 유효성 검사에 정규표현식을 사용했습니다. 원하는 조건으로 정규표현식을 검색하면 해당하는 식을 찾을 수 있습니다. 그 외의 값들은 각자의 조건에 맞게 검사를 진행하고 최종 값을 불린값으로 저장한 후 모든 불린 값을 확인해 가입하기 버튼을 활성화합니다.
// 이메일 유효성 검사 (xx@xxxx.xx)
const isEmail = email => {
const emailRegex = /^[a-z0-9_+.-]+@([a-z0-9-]+\.)+[a-z0-9]{2,4}$/;
return emailRegex.test(email);
};
const isEmailValid = isEmail(email);
// 비밀번호 유효성 검사 (대소문자,숫자,특수문자 포함 8자리 이상)
const isPw = pw => {
const pwRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/;
return pwRegex.test(pw);
};
const isPwValid = isPw(pw);
해당 입력창에 값을 입력할 경우 안내문구가 나오고, 유효성 검사를 통과하면 안내문구가 사라지게 설정했습니다. && 연산자를 사용한 조건부 렌더링으로 동작합니다.
{!isEmailValid && ( // 조건부 렌더링
<p
className="inputCheck"
style={{ display: email.length > 0 ? 'block' : 'none' }} // 삼항 연산자
>
* 이메일 양식을 맞춰주세요!
</p>
)}
select, option 태그를 활용한 생년월일 입력은 각각의 상수데이터를 별도로 만들어 사용했습니다.
<select className="select" name="year" onChange={handleInput}>
{YEAR.map(y => {
return <option key={y}>{y}</option>;
})}
</select>
<select className="select" name="month" onChange={handleInput}>
{MONTH.map(m => {
return <option key={m}>{m}</option>;
})}
</select>
<select className="select" name="day" onChange={handleInput}>
{DAY.map(d => {
return <option key={d}>{d}</option>;
})}
</select>
// 상수 데이터
// 년
export const YEAR = [];
const nowYear = new Date().getFullYear();
for (let i = 1980; i <= nowYear; i++) {
YEAR.push(i);
}
// 월
export const MONTH = [];
for (let i = 1; i <= 12; i++) {
let m = String(i).padStart(2, '0');
MONTH.push(m);
}
// 일
export const DAY = [];
for (let i = 1; i <= 31; i++) {
let d = String(i).padStart(2, '0');
DAY.push(d);
}
전체 유효성 검사를 묶어서 isAllValid 값을 선언하고 이를 활용해 버튼에 입력할 값을 설정합니다. 이를 활용해 버튼에 특정 클래스 명을 추가할 수 있습니다.
// 전체 유효성 검사
const isAllValid =
isEmailValid &&
isPwValid &&
isPwSame &&
isPhoneNumValid &&
isBirth &&
isTimeValid;
const activeBtn = isAllValid ? 'undefined' : 'disabled';
// 버튼 활성화
<div className={`signupBtn ${activeBtn}`} onClick={checkSignUp}>
가입하기
</div>
// 스타일 속성
.disabled {
opacity: 0.3;
pointer-events: none;
}
그 외에 사진 첨부 기능이나 백엔드 통신은 따로 포스팅할 예정입니다. 아래 전체코드를 첨부하지만 이후 리팩토링을 진행할 예정입니다.
import React, { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import { YEAR } from './YEAR';
import { MONTH } from './MONTH';
import { DAY } from './DAY';
import { LIMIT_TIME } from './LIMIT_TIME';
import './SignUp.scss';
function SignUp() {
const [imageUrl, setImageUrl] = useState(null);
const [userInput, setUserInput] = useState({
email: '',
pw: '',
pwCheck: '',
name: '',
gender: '',
phoneNum: '',
year: '',
month: '',
day: '',
time: '',
});
const { email, pw, pwCheck, name, gender, phoneNum, year, month, day, time } =
userInput;
const handleInput = e => {
const { name, value } = e.target;
setUserInput({ ...userInput, [name]: value });
};
// 프로필 사진 입력
const imgRef = useRef();
const onChangeImage = () => {
const reader = new FileReader();
const file = imgRef.current.files[0];
reader.readAsDataURL(file);
reader.onloadend = () => {
setImageUrl(reader.result);
};
};
// 이메일 유효성 검사
const isEmail = email => {
const emailRegex = /^[a-z0-9_+.-]+@([a-z0-9-]+\.)+[a-z0-9]{2,4}$/;
return emailRegex.test(email);
};
const isEmailValid = isEmail(email);
// 패스워드 유효성 검사
const isPw = pw => {
const pwRegex =
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/;
return pwRegex.test(pw);
};
const isPwValid = isPw(pw);
// 패스워드 재확인
const isPwSame = pw === pwCheck;
const pwDoubleCheck = !isPwSame ? 'pwDoubleCheck' : undefined;
// 휴대폰 번호 유효성 검사
const isPhoneNum = phoneNum => {
const phoneNumRegex = /01[016789]-[^0][0-9]{2,3}-[0-9]{4,4}/;
return phoneNumRegex.test(phoneNum);
};
const isPhoneNumValid = isPhoneNum(phoneNum);
// 생년월일 입력여부 확인
const isBirth = Boolean(year && month && day);
// 개인정보 유효기간
const isTimeValid = Boolean(time);
// 전체 유효성 검사 후 버튼 활성화
const isAllValid =
isEmailValid &&
isPwValid &&
isPwSame &&
isPhoneNumValid &&
isBirth &&
isTimeValid;
const activeBtn = isAllValid ? 'undefined' : 'disabled';
// 통신
const checkSignUp = e => {
e.preventDefault();
fetch('https://8075-211-106-114-186.jp.ngrok.io/users/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
body: JSON.stringify({
email: email,
password: pw,
name: name,
birthday: `${year}-${month}-${day}`,
phone_number: phoneNum,
gender: gender,
time: time,
}),
})
.then(response => {
if (response.ok === true) {
return response.json();
}
throw new Error('에러 발생!');
})
.catch(error => alert(error))
.then(data => {
if (data.ok === '회원가입 성공') {
alert('회원가입 성공');
<Link to="/login" />;
} else {
alert('회원가입 실패');
}
});
};
return (
<div className="signUp">
<form className="signUpBox">
<div className="profileBox">
<label className="imgBoxLabel" htmlFor="profileImg">
{imageUrl ? (
<img className="labelImg" src={imageUrl} alt="uploadImg" />
) : null}
<div className="imgUploadBtn">
<i className="fa-sharp fa-solid fa-camera" />
</div>
<input
id="profileImg"
className="profileImgInput"
type="file"
name="imageUrl"
ref={imgRef}
onChange={onChangeImage}
/>
</label>
</div>
{/* 이메일 비밀번호 입력 */}
<input
onChange={handleInput}
className="userInputEmail input"
name="email"
type="text"
placeholder="이메일"
autoComplete="username"
/>
<input
onChange={handleInput}
className="userInputPw input"
name="pw"
type="password"
placeholder="비밀번호"
autoComplete="current-password"
/>
<input
onChange={handleInput}
className={`userInputPwCheck input ${pwDoubleCheck}`}
name="pwCheck"
type="password"
placeholder="비밀번호 확인"
autoComplete="current-password"
/>
{!isEmailValid && (
<p
className="inputCheck"
style={{ display: email.length > 0 ? 'block' : 'none' }}
>
* 이메일 양식을 맞춰주세요!
</p>
)}
{!isPwValid && (
<p
className="inputCheck"
style={{ display: pw.length > 0 ? 'block' : 'none' }}
>
* 비밀번호는 대소문자, 숫자, 특수문자 포함 8자리 이상 적어주세요!
</p>
)}
{/* 이름 입력 */}
<p className="userName title mustInput">이름</p>
<input
onChange={handleInput}
className="userInputName input"
name="name"
type="text"
placeholder="이름을(를) 입력하세요"
autoComplete="username"
/>
{/* 성별 입력 */}
<p className="userGender title mustInput">성별</p>
<label className="userMale label">
<input
onChange={handleInput}
className="radio"
name="gender"
type="radio"
value="man"
/>
<span className="text">남자</span>
</label>
<label className="userFemale label">
<input
onChange={handleInput}
className="radio"
name="gender"
type="radio"
value="woman"
/>
<span className="text">여자</span>
</label>
{/* 휴대폰 입력 */}
<p className="userPhoneNum title mustInput">휴대폰</p>
<input
onChange={handleInput}
className="userInputNumber input"
name="phoneNum"
type="text"
placeholder="000-0000-0000 형식으로 입력하세요"
autoComplete="username"
/>
{!isPhoneNumValid && (
<p
className="inputCheck"
style={{ display: phoneNum.length > 0 ? 'block' : 'none' }}
>
* 숫자 사이에 하이픈(-)을 넣어주세요.
</p>
)}
{/* 생년월일 입력 */}
<div className="userBirth">
<p className="title mustInput">생년월일</p>
<div className="selectBox">
<select className="select" name="year" onChange={handleInput}>
{YEAR.map(y => {
return <option key={y}>{y}</option>;
})}
</select>
<select className="select" name="month" onChange={handleInput}>
{MONTH.map(m => {
return <option key={m}>{m}</option>;
})}
</select>
<select className="select" name="day" onChange={handleInput}>
{DAY.map(d => {
return <option key={d}>{d}</option>;
})}
</select>
</div>
</div>
{/* 개인정보 유효기간 */}
<div className="userDataSave">
<p className="name title">개인정보 유효기간</p>
{LIMIT_TIME.map(time => {
return (
<label key={time.id} className="one label">
<input
className="radio"
name="time"
type="radio"
value={time.value}
onChange={handleInput}
/>
<span className="text">{time.text}</span>
</label>
);
})}
</div>
<div className={`signupBtn ${activeBtn}`} onClick={checkSignUp}>
가입하기
</div>
</form>
</div>
);
}
export default SignUp;
@import '../../styles/variables.scss';
.signUp {
font-family: $NotoSans;
.signUpBox {
max-width: 500px;
margin: 50px auto 100px;
padding: 0 20px;
font-size: $fontSmall;
color: $colorDarkGray;
.title {
padding: 22px 0 13px;
font-weight: $weightSemiBold;
}
.mustInput {
&:after {
content: '';
display: inline-block;
width: 5px;
height: 5px;
margin: 0 0 3px 6px;
background: $colorRed;
border-radius: 50%;
}
}
.input {
width: 100%;
padding: 10px 15px;
border: 0.5px solid $colorGray;
font-size: $fontSmall;
outline: none;
&:nth-child(3) {
border-bottom: 0px;
border-top: 0px;
}
&:nth-child(4) {
margin-bottom: 5px;
}
}
.label {
display: flex;
padding-bottom: 4px;
.text {
padding-left: 5px;
}
}
.inputCheck {
padding: 5px 0 0 5px;
color: $colorRed;
font-size: $fontMicro;
}
.pwDoubleCheck {
border: 1px solid red;
}
.profileBox {
@include flexSort(center, center);
flex-direction: column;
margin-bottom: 30px;
.imgBoxLabel {
position: relative;
background: $colorLightGray;
width: 80px;
height: 80px;
border-radius: 50%;
.labelImg {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.profileImgInput {
display: none;
}
.imgUploadBtn {
@include flexSort(center, center);
position: absolute;
bottom: 0;
right: 0;
width: 30px;
height: 30px;
background: $colorDarkGray;
color: white;
border-radius: 50%;
cursor: pointer;
}
}
}
.selectBox {
.select {
padding: 8px 20px;
margin-right: 5px;
border: 0.5px solid $colorGray;
font-size: $fontSmall;
outline: none;
}
}
.signupBtn {
padding: 10px 15px;
margin-top: 40px;
color: $colorWhite;
background: $colorDarkGray;
font-size: $fontSmall;
text-align: center;
cursor: pointer;
}
.disabled {
opacity: 0.3;
pointer-events: none;
}
}
}