이메일, 비밀번호, 회원 정보 입력을 같은 페이지에서 하되, 각기 다른 컴포넌트를 만들어 사용자들이 회원가입할 때의 부담을 덜게 한다.
상태 표시줄을 두어 회원가입의 진행상황을 알려준다.
이메일, 비밀번호, 닉네임 작성시 정규식 부합 여부, 중복 여부를 즉각적으로 피드백해준다.
사용자에게 인증 이메일을 보내 인증하는 방식을 사용한다.
signup page가 사용하는 Signup 컴포넌트는 회원가입에 필요한 컴포넌트들의 전체적인 Layout을 관리해준다.
Redux를 이용하여 컴포넌트 관리해준다.
// Signup.tsx
import { useDispatch, useSelector } from "react-redux"
import { RootState } from "../../../store/store"
import SignupEmail from "./SignupEmail"
import Signupinfo from "./Signupinfo"
import Signuppw from "./Signuppw"
import SignupVerify from "./SignupVerify"
const Signup = () => {
// 컴포넌트 변경해줄 Redux 값
const signupComponent = useSelector((state: RootState) => state.signup.component)
const dispatch = useDispatch()
// 이메일, 비밀번호, 회원정보, 인증 컴포넌트로 나누었다.
return <>
{signupComponent === 'SignupEmail' && <SignupEmail />}
{signupComponent === 'Signuppw' && <Signuppw />}
{signupComponent === 'Signupinfo' && <Signupinfo />}
{signupComponent === 'SignupVerify' && <SignupVerify />}
</>
}
export default Signup
이메일 정규식 확인과 중복 체크까지 해준다.
// SinupEmail.tsx
import { useState } from 'react'
import { apiInstance } from '../../../apis/setting'
import { setSignup, setUser } from '../../../store/reducers/signupSlice'
import CurrentStatusFirst from '../CurrentStatus/CurrentStatusFirst'
import { CheckText, EmailDiv, ProgressBtn, SignupContainerDiv } from './Signup.style'
import { emailRegExp } from '../../../Lib/EmailRegExp'
import SocialLogin from '../Login/SocialLogin'
import { useDispatch } from "react-redux"
const SignupEmail = (props) => {
const dispatch = useDispatch()
// 이메일 정규식 확인
const [emailValid, setEmailValid] = useState({
touch: false,
valid: false,
})
const [checkEmail, setCheckEmail] = useState(false) // 이메일 중복 여부
const [email, setEmail] = useState('')
// 이메일 정규식 확인, 중복여부 확인 focus
const handleFocus = () => {
setEmailValid({
...emailValid,
touch: true,
})
}
// 이메일 정규식 확인, 이메일 중복 여부 확인
const handleChange = async (e) => {
const { value } = e.target
setEmail(value)
if (email.match(emailRegExp)) {
setEmailValid({
...emailValid,
valid: true,
})
try {
const response = await apiInstance.post('/users/check/email', { email: email })
if (response.data.isEmailExisted) setCheckEmail(true)
else setCheckEmail(false)
} catch (e) {
console.log(e.response)
setCheckEmail(false)
}
} else {
setEmailValid({
...emailValid,
valid: false,
})
}
}
// email 정보는 Redux에 저장, 그리고 컴포넌트는 비밀번호 입력하는 Sinuppw로 변경
const handleClick = async (e) => {
e.preventDefault()
dispatch(setUser({ key: 'email', value: email }))
dispatch(setSignup('Signuppw'))
}
return (
<SignupContainerDiv>
<CurrentStatusFirst />
<p>이메일 입력</p>
<EmailDiv>
<label htmlFor='email'>Email</label>
<input
type='email'
name='email'
id='email'
value={email}
placeholder='example@company.com'
onChange={handleChange}
onFocus={handleFocus}
autoComplete='off'
/>
<div>
{emailValid.touch === true &&
email.length > 0 &&
(emailValid.valid === true ? (
checkEmail === true ? (
<CheckText check={false}>존재하는 이메일입니다.</CheckText>
) : (
<CheckText check={true}>올바른 이메일 형식입니다.</CheckText>
)
) : (
<CheckText check={false}>올바르지 않은 이메일 형식입니다.</CheckText>
))}
</div>
</EmailDiv>
<ProgressBtn disabled={email === '' || !email.match(emailRegExp) || checkEmail} onClick={handleClick}>
다음
</ProgressBtn>
<SocialLogin />
</SignupContainerDiv>
)
}
export default SignupEmail
// Signuppw.tsx
import { CheckText, EmailDiv, ProgressBtn, SignupContainerDiv } from './Signup.style'
import { setSignup, setUser } from '../../../store/reducers/signupSlice'
import CurrentStatusSecond from '../CurrentStatus/CurrentStatusSecond'
import { useState } from 'react'
import { useDispatch } from "react-redux"
const Signuppw = (props) => {
import { useDispatch } from "react-redux"
const [password, setPassword] = useState({
first: '',
second: '',
})
const [touchedPw, setTouchedPw] = useState({
first: false,
second: false,
})
const handleChange = (e) => {
const { name, value } = e.target
setPassword({ ...password, [name]: value })
}
const handleFocus = (e) => {
const { name } = e.target
setTouchedPw({
...touchedPw,
[name]: true,
})
}
// 비밀번호 redux에 저장하고 컴포넌트는 Signupinfo로 변경
const handleClick = (e) => {
dispatch(setUser({ key: 'password', value: password.first }))
dispatch(setSignup('Signupinfo'))
}
return (
<>
<SignupContainerDiv>
<CurrentStatusSecond />
<p>비밀번호 설정</p>
<EmailDiv>
<label>비밀번호 입력</label>
<input
type='password'
name='first'
required
value={password.first}
onChange={handleChange}
placeholder='6자리 이상 입력해 주세요.'
onFocus={handleFocus}
/>
{touchedPw.first === true &&
(password.first.length >= 6 ? <CheckText check={true}>알맞은 비밀번호 입니다.</CheckText> : <CheckText check={false}>아직 6자리가 아니에요.</CheckText>)}
<label>비밀번호 확인</label>
<input
type='password'
name='second'
required
value={password.second}
onChange={handleChange}
placeholder='다시 한번 입력해 주세요.'
onFocus={handleFocus}
/>
{touchedPw.second === true &&
(password.first !== password.second ? <CheckText check={false}>두 비밀번호가 달라요!</CheckText> : <CheckText check={true}>일치합니다.</CheckText>)}
</EmailDiv>
<ProgressBtn disabled={password.first === '' || password.first !== password.second} onClick={handleClick}>
다음
</ProgressBtn>
</SignupContainerDiv>
</>
)
}
export default Signuppw
// Signupinfo.tsx
import { TitleDiv, ProgressBtn, SignupInfoContainer } from './Signupinfo.style'
import { useEffect, useState } from 'react'
import { apiInstance } from '../../../apis/setting'
import CurrentStatusThird from '../CurrentStatus/CurrentStatusThird'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../../store/store'
import { setSignup, setUser } from '../../../store/reducers/signupSlice'
import Router from 'next/router'
import SignupUserProfile from './SignupUserProfile'
import SignupUserInfo from './SignupUserInfo'
const Signupinfo = () => {
const userData: UserDataState = useSelector((state: RootState) => state.user.user)
const [select, setSelect] = useState(false)
// 소셜로그인할 때 미리 받아둔 소셜로그인 전용 이메일
const socialEmail = useSelector((state: RootState) => state.socialEmail.socialEmail)
const dispatch = useDispatch()
// 소셜 로그인일 경우 저장해둔 이메일 입력받음
useEffect(() => {
if (socialEmail) {
dispatch(setUser({ key: 'email', value: socialEmail }))
}
})
// 유저 정보 입력
const [info, setInfo] = useState({
nickname: '',
name: '',
gender: '',
image_URL: '',
})
const handleChange = (e) => {
e.preventDefault()
const { value, name } = e.target
setInfo({ ...info, [name]: value })
dispatch(setUser({ key: name, value: value }))
}
// 완료버튼, 이메일을 백엔드로 전송
// 패스워드가 없으면 소셜회원가입(이메일 인증x), 패스워드가 있으면 일반 회원가입
const handleClick = async () => {
// 패스워드 입력이 없은 소셜 로그인
if (userData.password === undefined) {
try {
await apiInstance.post('/users', userData)
Router.replace('/welcome')
} catch (e) {
console.error(e.response)
}
}
// 이메일, 패스워드 입력 받는 일반 로그인 + 인증 이메일
else {
try {
await apiInstance.post('/verify', { email: userData.email, type: 'email' })
dispatch(setSignup('SignupVerify'))
await apiInstance.post('/users', userData)
} catch (e) {
console.error(e.response.data)
}
}
}
return (
<SignupInfoContainer>
<CurrentStatusThird />
<TitleDiv>회원 정보 입력</TitleDiv>
{/* 닉네임, 이미지 입력 */}
<SignupUserProfile info={info} handleChange={handleChange} dispatch={dispatch} />
{/* 이름, 성별, 생일 입력 */}
<SignupUserInfo dispatch={dispatch} info={info} select={select} setSelect={setSelect} handleChange={handleChange} />
<ProgressBtn disabled={info.nickname === '' || info.name === '' || info.gender === '' || !select} onClick={handleClick}>
완료
</ProgressBtn>
</SignupInfoContainer>
)
}
export default Signupinfo
더 자세한 내용은 [Next.js] 프로필 이미지 업로드
// SignupUserProfile.tsx
import { UserProfileBox, UserProfileContainer } from "./Signupinfo.style"
import Image from "next/image"
import { CheckText } from "./Signup.style"
import { useEffect, useRef, useState } from "react"
import { apiInstance } from "../../../apis/setting"
import { setUser } from "../../../store/reducers/signupSlice"
import { toast } from "react-toastify"
// 닉네임, 이미지 입력
const SignupUserProfile = (props) => {
const { info, handleChange, dispatch } = props
// 닉네임 중복 여부
const [checkNickname, setCheckNickname] = useState({
click: false,
valid: false,
})
useEffect(() => {
const check = async () => {
try {
const response = await apiInstance.post('/users/check/nickname', { nickname: info.nickname })
if (!response.data.isNicknameExisted)
setCheckNickname({
...checkNickname,
valid: true,
})
else
setCheckNickname({
...checkNickname,
valid: false,
})
} catch (e) {
setCheckNickname({
...checkNickname,
valid: false,
})
}
}
check()
}, [info.nickname])
// profile_img
const [image, setImage] = useState('/blank.png')
const fileInput = useRef(null)
const handleImage = async (e: any) => {
const file = e.target.files[0]
if (!file) return
// 이미지 화면에 띄우기
const reader = new FileReader()
reader.readAsDataURL(file) // 파일에서 불러오는 메서드 / 종료되는 시점에 readyState는 Done(2)가 되고 onload 시작
reader.onload = (e: any) => {
if (reader.readyState === 2) {
// 파일 읽는 것이 성공했을 때, 2 반환 / 진행 중은 1, 실패는 0
setImage(e.target.result)
}
}
// 이미지 s3에 보내고 url 받기
const formData = new FormData()
formData.append('image', file)
try {
const imageRes = await apiInstance.post('/image', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
const image_URL = imageRes.data.imageURL
console.log(image_URL)
dispatch(setUser({ key: 'image_URL', value: image_URL }))
toast.success('success')
} catch (e) {
console.log(e)
toast.error('error')
}
// await apiInstatnce.delete('/image', {params: {imageName: ''}}) 이미지 삭제
}
return (
<UserProfileContainer>
<a
href='#'
onClick={() => {
fileInput.current.click()
}}
>
<Image src={image} width={150} height={150} alt='프로필 이미지입니다.' />
</a>
<UserProfileBox>
<input
type='text'
placeholder='닉네임을 입력하세요'
name='nickname'
value={info.nickname}
onChange={handleChange}
autoComplete='off'
onFocus={() => {
setCheckNickname({ ...checkNickname, click: true })
}}
/>
{checkNickname.click &&
info.nickname.length > 0 &&
(checkNickname.valid ? <CheckText check={true}>사용 가능한 닉네임입니다.</CheckText> : <CheckText check={false}>중복된 닉네임입니다.</CheckText>)}
<label htmlFor='input-file'>이미지 선택</label>
<input type='file' name='image_URL' id='input-file' accept='image/*' style={{ display: 'none' }} ref={fileInput} onChange={handleImage} />
</UserProfileBox>
</UserProfileContainer>
)
}
export default SignupUserProfile
생일 입력은 별도의 라이브러리를 사용하기 때문에 SignupUserBirth 컴포넌트에 따로 담아두었다.
// SignupUserInfo.tsx
import { UserInfoContainer, UserSex } from "./Signupinfo.style"
import SignupUserBirth from "./SignupUserBirth"
const SignupUserInfo = (props) => {
const { handleChange, select, setSelect, info } = props
return (
<UserInfoContainer>
<div>이름</div>
<input type='text' name='name' value={info.name} placeholder='이름을 입력하세요' onChange={handleChange} autoComplete='off' />
<div>성별</div>
<UserSex>
<label>
남성
<input type='radio' value='남성' name='gender' onChange={handleChange} checked={info.gender === '남성'} />
</label>
<label>
여성
<input type='radio' value='여성' name='gender' onChange={handleChange} checked={info.gender === '여성'} />
</label>
</UserSex>
<SignupUserBirth setSelect={setSelect} select={select} />
</UserInfoContainer>
)
}
export default SignupUserInfo
// SignupUserBirth.tsx
import moment from "moment"
import { useCallback, useEffect, useState } from "react"
import { setUser } from "../../../store/reducers/signupSlice"
import { BirthInfo } from "./Signupinfo.style"
import { Calendar } from 'react-date-range'
import ko from 'date-fns/locale/ko'
import { useDispatch } from "react-redux"
const SignupUserBirth = (props) => {
const { select, setSelect } = props
const [birth, setBirth] = useState('')
const dispatch = useDispatch()
// birth calendar
const [showCalendar, setShowCalendar] = useState<boolean>(false) // 캘린더 토글
const today = moment().toDate()
const [date, setDate] = useState<Date>(today) // date 선언하고 기본갓을 오늘 날짜로 지정
const onChangeDate = useCallback((date: Date): void | undefined => {
if (!date) {
return
}
setDate(date)
// const dateTimestamp = Date.parse(String(date))
setShowCalendar(false)
setBirth(date.toLocaleDateString().replaceAll(' ', ''))
setSelect(true)
}, [])
const clickHandler = () => {
setShowCalendar(true)
setSelect(false)
}
useEffect(() => {
dispatch(setUser({ key: 'birth', value: birth }))
}, [showCalendar])
return (
<>
<div>생년월일</div>
<BirthInfo select={select}>
<button onClick={clickHandler}>{!select && showCalendar ? '생년월일 선택' : '선택 완료!'}</button>
{showCalendar && (
<Calendar locale={ko} months={1} maxDate={new Date()} date={date} onChange={onChangeDate} dateDisplayFormat={'yyyy.mm.dd'} />
)}
{!showCalendar && <><span>내 생일: </span>
<p>{date.toLocaleDateString()}</p></>}
</BirthInfo>
</>
)
}
export default SignupUserBirth
회원 모든 정보를 입력하고 나고 이메일 인증을 통해 사용자 인증까지 받으면 회원가입이 완성이 된다.
그 이후의 과정의 백엔드 코드를 더 알고 싶다면 같이 프로젝트한 분의 글을 보면 된다.
[Node.js] Mailgun으로 이메일 전송하기
[Node.js] 회원가입 이메일 인증 구현
가장 기본적인 회원가입 구현이지만 가장 오래걸리는 작업이기도 했다.