[Next.js] Redux-toolkit 이용하여 회원가입 구현

JunSeok·2023년 2월 12일
0

Movie-inner 프로젝트

목록 보기
9/13
post-thumbnail

구현 목표

이메일, 비밀번호, 회원 정보 입력을 같은 페이지에서 하되, 각기 다른 컴포넌트를 만들어 사용자들이 회원가입할 때의 부담을 덜게 한다.
상태 표시줄을 두어 회원가입의 진행상황을 알려준다.
이메일, 비밀번호, 닉네임 작성시 정규식 부합 여부, 중복 여부를 즉각적으로 피드백해준다.
사용자에게 인증 이메일을 보내 인증하는 방식을 사용한다.

구현 과정

전체적인 Layout

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

비밀번호 입력

비밀번호 체크 UI를 넣어주면 훨씬 좋다.
// 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}>두 비밀번호가 달라요&#33;</CheckText> : <CheckText check={true}>일치합니다.</CheckText>)}
                </EmailDiv>
                <ProgressBtn disabled={password.first === '' || password.first !== password.second} onClick={handleClick}>
                    다음
                </ProgressBtn>
            </SignupContainerDiv>
        </>
    )
}

export default Signuppw

회원정보 입력

  • 프로필 이미지, 닉네임, 이름, 성별, 생일 입력하고 회원가입 정보 입력을 마무리한다.
  • 프로필 이미지 설정하는 방법은 [Next.js] 프로필 이미지 업로드
  • 닉네임 중복 체크해주고, 생일 입력은 react-date-range 캘린더 라이브러리를 사용했다.
  • 회원 정보 입력하는 컴포넌트가 복잡해서 닉네임, 이미지 입력해주는 SignupUserProfile과 SignupUserInfo로 나누었다.
  • 소셜로그인같은 경우 이메일을 미리 받았고, 비밀번호는 필요없으니 회원정보만 따로 받아서 회원가입한다.
// 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

프로필 이미지, 닉네임 입력받는 SignupUserProfile.tsx

더 자세한 내용은 [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

이름, 성별, 생일 입력받는 SignupUserInfo.tsx

생일 입력은 별도의 라이브러리를 사용하기 때문에 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
// 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] 회원가입 이메일 인증 구현

가장 기본적인 회원가입 구현이지만 가장 오래걸리는 작업이기도 했다.

깃헙코드

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글