[React 프로젝트] 숫자야구 프로젝트 - 로그인(feat. cookies)

Hyuk·2023년 1월 14일
0

Home 컴포넌트가 하는 일은 다음과 같다.

  • 사용자가 로그인을 할 때 쿠키값을 저장하고, 로그아웃을 할 때 쿠키값을 삭제하는 기능.
  • 사용자가 로그인을 할 때 백엔드에 profile이라는 데이터를 전송하고, 로그인을 한 상태와 하지 않은 상태의 UI 구현
    • 만일 실패했을 시에 ValidationForm에 대한 기능.
  • 게임 시작 전 난이도 설정에 관한 기능.
  • 게임 시작함과 동시에 게임티켓을 발행하여, 사용자의 게임 기록을 저장하는 기능.


전체 코드

// src/view/Home/index.js
import React, { useCallback, useState } from 'react'
import { useHistory } from 'react-router-dom'
import './index.css'
import { useCookies } from 'react-cookie';
import axios from 'axios'
import ValidationForm from '../../components/loginValidationForm'

const Home = () => {
    const history = useHistory()
    const [cookies, setCookie, removeCookie] = useCookies(['data']);
    const [inputStatus, setInputStatus] = useState('')
    const [code, setCode] = useState(0)
    const [profile, setProfile] = useState({
        id: '',
        password: '',
        nickname: cookies.data?.nickname
    })

    const setGlobalCookie = (id, nickname) => {
        setCookie('data', {id, nickname}, {maxAge:'43200000'}, {path:'/'});
    }
    
    const setLevelOfDifficult = useCallback((radioBtnName) => {
        if (!inputStatus) {
            alert('난이도를 설정해주세요!')
            return
        }
        history.push({
            pathname: '/play-ground',
            level: radioBtnName,
        })
    }, [inputStatus])

    const handleClickRadioButton = (radioBtnName) => {
        setInputStatus(radioBtnName)
    }

    const onGameStartButtonClick = useCallback(() => {
        axios.post('http://localhost:65100/ticket', {id: cookies.data.id, nickname: cookies.data.nickname})
        setLevelOfDifficult(inputStatus)
    }, [inputStatus])

    const onJoinMemberButtonClick = useCallback(() => {
        history.push('/JoinMembership')
    }, [])

    const onlogoutButtonClick = useCallback(() => {
        if (window.confirm("정말 로그아웃을 하시겠습니까?")) {
            removeCookie('data')
          }
    }, [])

    const goGameStart = useCallback(() => {
        history.push('/')
    }, [])

    const onLoginClick = useCallback(async () => {
        await axios.post('http://localhost:65100/auth', profile).then((response) => {
            alert('로그인을 성공하셨습니다.')
            setGlobalCookie(`${response.data.id}`, `${response.data.nickname}`) 
            goGameStart()
        }).catch((error) => {
            const {code, message} = error.response.data
            if(code === 3) {
                setCode(code)
            }
            if(code === 4) {
                setCode(code)
            }
        })
    }, [profile])

    return (
        <div className="container">
            {
                !cookies.data ? (
                <div>
                    <div className='login-container rounded-lg ... shadow-inner ...'>
                        <div className='game-title'>
                            <h1>Number Baseball!</h1>
                        </div>
                        <div className='login-wrap'>
                            <div className="login-id-form">
                                <ValidationForm
                                    id='login-id-input'
                                    className='login-input border-solid border border-gray-400 font-sans ... rounded-sm ... '
                                    placeholder='아이디를 입력해 주세요.'
                                    maxLength='15'
                                    value={profile.id}
                                    validations={[
                                        () => ({
                                            result: code === 3,
                                            errorMessage: '아이디를 다시 입력해주세요.'
                                        })
                                    ]}
                                    onChange={(event) => {setProfile({...profile, id: event.target.value})}}
                                    />
                            </div>
                            <div className='login-pw-form'>
                                <ValidationForm
                                    id='login-password-input'
                                    className='login-input border-solid border border-gray-400 font-sans ... rounded-sm ...' 
                                    type='password'
                                    placeholder='비밀번호를 입력해 주세요.'
                                    maxLength='30'
                                    value={profile.password}
                                    validations={[
                                        () => ({
                                            result: code === 4,
                                            errorMessage: '아이디나 비밀번호를 다시 입력해주세요.'
                                        })
                                    ]}
                                    onChange={(event) => {setProfile({...profile, password: event.target.value})}}
                                    />
                            </div>
                            <button className='login-btn rounded-3xl font-sans ... bg-blue-600 text-base ...' onClick={onLoginClick}>로그인</button>
                        </div>
                        <button
                            className='join-btn text-gray-400 underline ...'
                            onClick={onJoinMemberButtonClick}>
                            회원가입 하기
                        </button>
                    </div>
                </div>
                ) : (
                    <div>
                        <div className='game-start-container rounded-lg ...'>
                            <h1 className='title font-sans ... text-3xl ... font-semibold ...'>
                                {cookies.data?.nickname}<div> 
                                    Number BaseBall!
                                </div>
                            </h1>
                            <button
                                className="game-start-button  rounded-3xl font-sans ... bg-blue-600 text-base ..."
                                onClick={onGameStartButtonClick}>
                                게임 시작
                            </button>
                            <div className='cont-level'> 
                                <label htmlFor="easy" className='cont-radio'>쉬움
                                    <input type="radio" 
                                        onClick={() => handleClickRadioButton(3)} 
                                        className='level'
                                        name="q1-btnradio"
                                        id="easy" />
                                    <span className='checkmark'></span>
                                </label>
                                <label htmlFor="medium" className='cont-radio'>보통
                                    <input type="radio"
                                        onClick={() => handleClickRadioButton(4)}
                                        className='level'
                                        name="q1-btnradio" 
                                        id="medium" />
                                    <span className='checkmark'></span>
                                </label>
                                <label htmlFor="difficult" className='cont-radio'>어려움
                                    <input type="radio"
                                        onClick={() => handleClickRadioButton(5)}
                                        className='level'
                                        name="q1-btnradio" 
                                        id="difficult" />
                                    <span className='checkmark'></span>
                                </label> 
                            </div>
                            <button
                                className='logout-button text-gray-400 underline ...'
                                onClick={onlogoutButtonClick}>
                                로그아웃
                            </button>
                        </div>
                    </div>
                )
            }
        </div>
    )
}

export default Home

전체 코드로 보니 잡다한 코드가 많아 보이는데

기능 위주로 하나씩 살펴보도록 하자.

Cookies

먼저 쿠키값이다. 사용자의 로그인 유무를 판단하기 위해 쿠키를 사용했다. 사용자가 로그인을 할때 쿠키값을 저장하고 로그아웃을 할 때 쿠키값을 삭제하는 방식으로 데이터를 관리했으며, 이를 통해 나타내는 화면도 다르게 구성한다.

React 에서 쿠키를 사용하기 앞서 설치와 기본설정이 필요하다.

yarn add react-cookie
import { useCookies } from 'react-cookie';

설치와 import를 완료하면 다음과 같이 사용할 수 있다.

const [cookies, setCookie, removeCookie] = useCookies(['data']);

다음은 Home 컴포넌트 내의 쿠키 관련 코드이다. 다음과 같이 Cookies 값을 저장하고 삭제하고, Cookies 값의 유무에 따라 삼항연산자를 통해 나타내는 뷰를 다르게 구성했다.

useCallback 에 대한 설명은 추후에 기록하겠다.

const Home = () => {
	
	const setGlobalCookie = (id, nickname) => {
      setCookie('data', {id, nickname}, {maxAge:'43200000'}, {path:'/'});
  }

	// 로그인버튼을 누르면 Cookies값 저장
	const onLoginClick = useCallback(async () => {
	  await axios.post('http://localhost:65100/auth', profile).then((response) => {
	      alert('로그인을 성공하셨습니다.')
	      setGlobalCookie(`${response.data.id}`, `${response.data.nickname}`)
	      goGameStart()
	  }).catch((error) => {    
			... 
		}
	}

	// 로그아웃버튼을 누르면 Cookies값 삭제
	const onlogoutButtonClick = useCallback(() => {
	        if (window.confirm("정말 로그아웃을 하시겠습니까?")) {
	            removeCookie('data')
	          }
	    }, [])

	// Cookies값 유무에 따라 뷰 전환
	return (
  <div className="container">
      {
        !cookies.data ? (
					...
        ) : (
				...
			)
	}
}

setCookie라는 함수로 Cookies값을 설정할 수 있다.

// 기본 문법
setCooikes(name, value, [options])

name: Cookies의 이름
value: Cookies의 값
option(객체): path(경로): 쿠키값을 저장하는 서버 경로, 기본은 /이고, 모든 페이지에서 접근가능. 만약 /home 이라면 domain.com/home 에서만 쿠키에 접근할 수 있다.
maxAge: 클라이언트가 쿠키를 수신한 시점부터 쿠키의 상대적인 최대 수명(초)

백엔드와 통신

사용자가 아이디와 비밀번호를 입력하고 로그인 버튼을 눌렀을 때, 사용자가 알맞은 아이디와 비밀번호를 입력했는지 백엔드와 통신해서 판단해야한다. 알맞은 아이디와 비밀번호를 입력했다면 위처럼 쿠키값을 저장하고 로그인된 화면을 나타내고, 알맞지 않은 아이디와 비밀번호를 입력했다면 ValidationForm을 통해 유저에게 메시지를 전달한다.

백엔드와 통신하기 위해 해당 프로젝트에서 axios 라는 라이브러리를 설치하고 사용했다.
다음과 같이 axios를 설치한 이후 import를 해준다.

yarn add axios
import axios from 'axios'

ValidationForm

axios를 이용해서 백엔드와 통신하기 이전에, 사용자가 입력한 아이디와 비밀번호를 한 객체에 담아서 전해줘야 하고, 유저가 입력한 정보로 로그인을 성공할 수 있는지에 대한 유효성 검사를 위해 ValidationForm이라는 컴포넌트를 만들었다.

굳이 하나의 컴포넌트를 만들어서 유효성 검사를 한 이유는 확장성에 용이하기 때문이다.
컴포넌트를 사용하지 않고 input 태그를 이용해서 데이터를 주고받는다면
모든 유효성 검사 시에 input 태그를 배치해서 코드를 작성하는 데에 비해, 컴포넌트를 운영한다면 이러한 작업을 한번에 진행하기 때문에 가시성도 높을뿐더러 코드의 효율성이 배가 될것이다.
다음과 같이 components 폴더 안에 loginValidationForm을 만들었다.

지금보면 회원가입 유효성 검사와 로그인 유효성 검사 컴포넌트를 한번에 처리할 수 있어 보인다.
(회원가입 할 때에도 컴포넌트를 만들었었다,,,)

// src/components/loginValidationForm.js
import React, { useState, useMemo } from 'react'

const ValidationForm = ({
    value,
    validations = [],
    ...props  
    }) => {

    const [errorMessage, setErrorMessage] = useState('')

    const isValid = useMemo(() => {
        for (const validation of validations)
            const { result, errorMessage } = validation(value)
            if(!result) {
                setErrorMessage(errorMessage)
                return result
            } 
        }
            
        return true
    }, [validations])

    return(
        <div>
            <input value={value} {...props}/>
            <span>{
               isValid ? 
               <span>{errorMessage}</span> :
               <span></span>
           }</span>
        </div>
    )
}

export default ValidationForm

그리고 위의 LoginValidationForm 컴포넌트를 Home 컴포넌트에 적용한 코드이다.

// src/view/Home/index.js
const [profile, setProfile] = useState({
    id: '',
    password: '',
    nickname: cookies.data?.nickname
})

...

<div>
  <div>
      <ValidationForm
          placeholder='아이디를 입력해 주세요.'
          maxLength='15'
          value={profile.id}
          validations={[
              (value) => ({
                  result: code === 3,
                  errorMessage: '아이디를 다시 입력해주세요.'
              })
          ]}
          onChange={(event) => {setProfile({...profile, id: event.target.value})}}
        />
  </div>
  <div>
      <ValidationForm
          type='password'
          placeholder='비밀번호를 입력해 주세요.'
          maxLength='30'
          value={profile.password}
          validations={[
              () => ({
                  result: code === 4,
                  errorMessage: '아이디나 비밀번호를 다시 입력해주세요.'
              })
          ]}
          onChange={(event) => {setProfile({...profile, password: event.target.value})}}
        />
  </div>

먼저 onChange 함수로 사용자가 입력하는 value값을 추적해서 변수에 담는다.
validations라는 props를 통해 ValidationForm 컴포넌트 안에 배열로 함수를 나열시킬 수 있도록 하였고, resulterrorMessage값을 구조분해 할당을 통해 개별변수로 담아서 함수를 이용해 result을 기준으로 검사를 진행했다.

검사에 실패했을 경우, 경고문구는 아이디와 비밀번호중 어느 것이 틀린것인지 특정해주지 않는다. 보안상의 이유로 “아이디 또는 비밀번호를 확인해주세요.” 라는식으로 불분명하게 적어줬다.


다시 axios로 돌아와, 사용자의 아이디와 비밀번호를 onChange 함수로 profile 변수에 담아서
다음과 같은 방식으로 axios를 이용해 백엔드와 통신했다.

// src/view/Home/index.js
const [code, setCode] = useState(0)
const onLoginClick = useCallback(async () => {
    await axios.post('http://localhost:65100/auth', profile).then((response) => {
        alert('로그인을 성공하셨습니다.')
        setGlobalCookie(`${response.data.id}`, `${response.data.nickname}`) 
        goGameStart()
    }).catch((error) => {
        const {code, message} = error.response.data
        if(code === 3) {
            setCode(code)
        }
        if(code === 4) {
            setCode(code)
        }
    })
}, [profile])

위의 코드를 보면 if(code === 3) 을 볼 수가 있는데
이는 백엔드에서 데이터를 불러올 때 간단하게 코드를 받아 에러를 나타낼 수 있도록 하였다.

로그인 유효성 검사 시엔 어떤 부분이 틀렸는지 특정해주지 않아서 필요성이 반감되지만, 회원가입 유효성 검사시엔 아이디가 중복됐는지, 닉네임이 중복됐는지 등 특정해줘야하기 때문에 위의 방식처럼 에러를 구분해줬다.

난이도 설정

사용자가 알맞은 아이디와 비밀번호를 입력했다면, 쿠키값이 저장되어 있으므로 로그인된 화면이 나타날것이다. 사용자가 설정한 닉네임과 함께 게임제목을 나타내는 텍스트를 보여주고, 난이도를 설정할 수 있도록 배치하였다.
게임의 난이도는 숫자야구 게임과 걸맞게 4개를 기준으로 정답의 개수를 줄이거나 늘린 방식으로 난이도를 나타내었다.
쉬움은 3, 보통은 4, 어려움은 5라는 숫자를 대입해줌으로써 다음과 같이 코드를 작성하였다.

//src/view/Home/index.js
const [inputStatus, setInputStatus] = useState('')

const setLevelOfDifficult = useCallback((radioBtnName) => {
    if (!inputStatus) {
        alert('난이도를 설정해주세요!')
        return
    }
    history.push({
        pathname: '/play-ground',
        level: radioBtnName,
    })
}, [inputStatus])

const handleClickRadioButton = (radioBtnName) => {
      setInputStatus(radioBtnName)
  }

...

<div> 
  <label htmlFor="easy">쉬움
      <input type="radio" 
          onClick={() => handleClickRadioButton(3)}
          name="q1-btnradio"
          id="easy" />
      <span className='checkmark'></span>
  </label>
  <label htmlFor="medium">보통
      <input type="radio"
          onClick={() => handleClickRadioButton(4)}
          name="q1-btnradio" 
          id="medium" />
      <span className='checkmark'></span>
  </label>
  <label htmlFor="difficult">어려움
      <input type="radio"
          onClick={() => handleClickRadioButton(5)}
          name="q1-btnradio" 
          id="difficult" />
      <span className='checkmark'></span>
  </label> 
</div>

라디오 버튼을 통해 하나만 선택할 수 있는 버튼을 배치했고,
각 버튼을 클릭시 handleClickRadioButton 함수에 그 매개변수로 값들을 전달 후
useState로 값을 관리해준다.
그 후 게임을 진행하는 /play-ground 페이지에 level이라는 변수로 값을 전달했다.

참고로 /play-ground 페이지에서는 다음과 같은 방식으로 데이터를 받을 것이다.
전달받은 데이터의 값은 다음 이미지처럼 보여진다.

history, location, match에 대한 내용은 다음챕터에 기록해두겠다.

// src/view/Game/index.js
const [level] = useState(location.location.level)
const [answerLength] = useState(level)

게임 티켓 발행

게임 티켓이라 함은 사용자가 게임에 입장할 때 발행해주고, 게임에 대한 정답과 정답 시도 등 게임 종료 후 사용자가 볼 수 있는 게임 기록을 저장하는 기능을 한다.

더 자세한 건 백엔드 game_history 테이블에 기록해두겠다.

게임에 입장할 때 티켓을 발행해 주므로, 게임시작 버튼을 눌렀을때, 아이디와 닉네임 데이터값을 전해주었다.
해당 페이지에서는 아이디와 닉네임의 데이터 값만 가지고 있기 때문에 그 부분만 발행했지만, 게임을 진행하는 페이지인 /play-ground 에서는 정답, 시도, 결과 등을 같이 발행할것이다.

// src/view/Home/index.js
const onGameStartButtonClick = useCallback(() => {
  axios.post('http://localhost:65100/ticket', {id: cookies.data.id, nickname: cookies.data.nickname})
  setLevelOfDifficult(inputStatus)
}, [inputStatus])

useCallback

개요

메모리제이션된 함수를 반환한다. 컴포넌트가 렌더링 될 때마다 내부에 선언되어 있던 표현식(변수, 함수 등)도 매번 다시 선언되어 사용된다. 하지만 하나의 객체에서 하나의 값만 변경될때 전부 선언되지않고, 첫 마운트될 때 한번만 선언해서 재사용한다면 더욱 효율적이기 때문에 useCallback함수를 사용한다.

useCallback함수가 유용하게 쓰이는 대표적인 예로 의존배열(deps)에 함수를 넘길 때이다. 다음을 보면 sum1sum2는 동일한 코드 소스를 공유하지만, 리턴하는 오브젝트가 다르다. 오브젝트는 오로지 자기 자신만 동일하기 때문이다. 이처럼 동일한 소스코드로 다른 함수 인스턴스가 생성 되는 경우가 있다.

자바스크립트에서 함수는 객체로 취급되기 때문에 메모리 주소에 의한 참조 비교가 일어나기 때문이다.

const sum1 = sum()
const sum2 = sum()

function sum() {
  return (a, b) => a + b
}

sum1 === sum2 // false
sum2 === sum2 // true

dependecy로 함수를 넘길 때

그리고 다음을 보자. useEffect함수는 deps에 넘어온 값이 변경될 때만 함수를 호출한다. 여기에선 fetchUser라는 함수가 deps로 들어와있는데, fetchUser는 함수이기 때문에 userID값이 바뀌든 말든 컴포넌트가 렌더링될 때 마다 새로운 참조값으로 변경이 된다.

그럼 useEffect가 다시 호출 → user값변경 → 리렌더링 이런 악순환에 빠지게 된다.

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = () =>
    fetch(`https://your-api.com/users/${userId}`)
      .then(({ user }) => user);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser])

이 상황에선 fetchUser라는 함수를 useCallback으로 감싸주면서 fetchUser함수의 참조값을 동일하게 유지시킬 수 있다

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = useCallback(() =>
      fetch(`https://your-api.com/users/${userId}`)
        .then(({ user }) => user), [userId]);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

자식 컴포넌트에 콜백을 넘겨줄 때

결론을 먼저 말하자면, 함수를 자식컴포넌트에 props로 넘겨줄 때는 항상 useCallback을 사용한다. 그렇지 않으면 자식컴포넌트는 계속 새로운 함수를 생성한다고 받아들이기 때문에 렌더링이 계속 실행된다. 이유는 위와 같이 함수의 참조값이 매번 바뀌기 때문에 자식 컴포넌트도 리렌더링되며 성능이 떨어지거나, 의도한대로 값이 나오지 않기 때문이다.

profile
프론트엔드 개발자

0개의 댓글