메가바이트 스쿨 15주차 (3/20) react

정영찬·2023년 3월 20일
0
post-thumbnail

json-server, cookie

Json Server 사용해서 백엔드 체험

1) json server

간단하게 백엔드 서버를 구축할수 있도록 도와주는 역할

직접 글을 생성, 수정, 삭제하는 작업까지 json server를 이용해서 진행한다.

yarn add json-server

2) 서버 만들기

앱 최상단 경로에 아래와 같은 형태로 db.json을 제작한다.

{
  "posts": [
    {
      "title": "First Post",
      "body": "First Content",
      "id": 1
    }
  ]
}

위처럼 작성하고, 서버를 실행한 후에, 서버주소/posts로 접근하면 해당 배열안에 잇는 내용들을 수정/삭제/생석/읽기가 가능하게 된다.

그리고 package.json 의 scripts 부분을 아래처럼 수정한다

  "scripts": {
        "dev": "vite",
        "build": "vite build",
        "preview": "vite preview",
        "server": "json-server --watch db.json --port 8000"
    },

3) postman 사용하기

https://www.postman.com/downloads/ : 설치링크

아까 실행해서 켜놓은 서버 주소를 + posts를 입력하고 send 버튼을 누르면 하단부분에 응답이 표시된다.

이제 GET 대신에 POST로 변경하고 Body 선택 후에 raw를 클릭한 다음 가장 우측에 있는 타입을 json으로 바꾼다.
그리고 하단의 텍스트 입력창에 {”title”: “제목", “body”: “내용”} 와 같은 방식으로 작성해서 send를 눌러본다.

이제 다시 GET으로 확인해보면

글 2개가 추가되어있다.

DELETE 나 PUT 도 가능하다.
DELETE의 경우에는 posts 뒤에 /글id를 붙여서 요청을 보내면 된다.
PUT 도 마찬가지로 잘 작동한다.

4) rdb?

rdb란 relational database를 뜻한다.
데이터베이스는 데이터베이스인데, 아느이 데이터들이 서로 관계를 맺으면서 이루어져있다는 뜻이다.

정보를 저장하기 위해서는 db가 필수적이다.
db 내부에서 특정 정보에 대해서 정보를 담는 공간을 테이블 이라고 한다.
비유를 하자면, db는 액셀 파일이고, 테이블은 엑셀 파일 내부의 시트라고 생각하면 된다!
시트를 여러개 만들수 있듯이, 테이블도 여러개를 만들수 있다.

유저가 글쓰는 기능을 만든다고 생각하면,
user 테이블과 post 테이블이 각각 존재해야한다.

테이블은 아래와 같이 생겼다고 생각해보면

post 테이블

idtitlebodyuserId
1제목1내용11
2제목2내용22

post 테이블

idnamepassword
1바지락1234
2칼국수123

우리는 Post 테이블 내부의 userId 를 보고, User 테이블로 가서 해당 유저의 이름을 알 수 있다.

id 가 1인 글의 경우, userId 가 1 이므로, User 테이블에서 id 가 1인 사람을 찾으면, ‘바지락' 이라는 사람이 글을 썼다는 것을 알 수 있는 것이죠!

이렇게 테이블을 서로 연결짓는 필드를 foreign key 라고 한다.

그리고 이렇게 id로 특정한 유저를 구분짓기 위해서는 , 해당 id 가 unique 해야 한다. 다른 유저랑 id 가 똑같다면 userId 만으로 다른 유저와 구별을 지을수 없게 된다.

그래서 이렇게 각 테이블마다 존재하는 unique 한 id 를 primary key 라고 한다.

보통은 아래와 같이 연결 구조를 표현하는 diagram 을 그려서 db 에 대한 초기 설계를 진행하게 된다.

간단한 블로그 만들기

1) 읽어오기 기능

아까 postman에서 get으로 데이터 조회를 해서 나온 데이터를 react를 통해 기능을 구현해보자

axios를 사용해서 json-server에서 get을 통해 얻은 데이터를 사용해서 Posts컴포넌트를 제작한다.

import React, { useEffect, useState } from 'react'
import axios from 'axios'

function Posts() {
    const [data, setData] = useState(null)
    const getData = async () => {
        const response = await axios.get('http://localhost:8000/posts')
        setData(response.data)
    }
    useEffect(() => {
        getData()
    }, [])

    return (
        <div>
            Posts
            {data && data.map((post) => <p key={post.id}>{post.body}</p>)}
        </div>
    )
}

export default Posts

2) 글쓰기 기능

3) 수정/삭제 기능

회원가입 / 로그인 기능 구현

1) JWT

jwt 는 json web token 을 줄인 말로, 특정한 json 데이터 (유저 정보) 에 대해서 암호화를 하여, 이를 유저의 인증 정보로서 사용한다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im5pbHNvbkBlbWFpbC5jb20iLCJwYXNzd29yZCI6Im5pbHNvbiIsImlhdCI6MTY2MDExMDg0NSwiZXhwIjoxNjYwMTE0NDQ1fQ.SHB0bs4GOxZwyTDMzjJGw0BVLYWr8Q4UsdibJtj54d0

이렇게 생겼는데, 여기에 유저 정보와 함께, 언제까지 해당 토큰을 쓸수 있는지도 담겨있다.

특정 유저가 로그인되었는지 확인할때, 이 토큰을 사용한다.

여기에 유저 정보가 담겨있기 때문에, token을 이용해서 어떤 유저가 지금 배겐드로 요청을 보내고 있는지도 확인할 수 있다.

백엔드는 프론트엔드가 token 을 가진 상태 (header 에 token 을 넣은 상태) 로 요청을 보내면, 이 token 이 유효한 token 인지 확인하고, 누가 요청을 보낸건지, 지금 이 요청을 보낼 권한이 있는지까지도 확인한다.

→ 마치 술집에 들어갈 때 신분증을 제시하면, 신분증이 우선 유효한 신분증인지 확인하고, 내가 누구인지 보면서 연령을 확인하는 것과 비슷하다.

  • jwt 구조

HEADER, PAYLOAD, VERIFY_SIGNATURE 로 이루어져 있으며, 아래와 같이 각 데이터는 온점(.)으로 구분된다.

HEADER 는 JWT 검증에 필요한 토큰 타입과 알고리즘에 대한 정보를 담고 있다. (현재 토큰이 어떻게 암호화되어있는지를 저장)

HEADER 정보
{
  "typ": "JWT",    # 토큰 타입
  "alg": "HS256"   # 알고리즘
}

PAYLOAD 는 실질적으로 인증에 필요한 데이터를 저장한다. 데이터 각각의 필드를 클레임(claim) 이라고 하고, 대부분의 경우 클레임에 username 또는 user_id 를 포함합니다.

또한 페이로드에는 토큰 발행시간(iat)토큰 만료시간(exp) 이 있어서, 토큰을 현재 시점에서 사용할 수 있는지 여부를 판단하기 위한 용도로도 사용된다.

PAYLOAD 정보
{
  "token_type": "access", # 토큰의 종류
  "exp": 1656293275, # 토큰의 만료시간
  "iat": 1656293095, # 토큰의 발행시간
  "jti": "3b46ec59ce1e4da641f9f317adb9f6a3", # 토큰 아이디
  "user_id": 1 # 사용자의 아이디값
}

VERIFY SIGNATURE 는 토큰의 진위여부를 판단하는 용도로 사용되며, header 와 payload 값을 합친 후에, SECRET_KEY 와 함께 암호화 알고리즘을 사용해서 암호화 한다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
	SECRET_KEY
)
  • Access Token 과 Refresh Token

jwt 인증 방식의 경우 보안을 위해서 두 가지 토큰을 함께 사용한다.

  1. Access Token : 말그대로 요청을 보낼 때 (접근할 때) 사용하는 토큰.
  2. Refresh Token : access token 을 재발급받을 때 사용하는 토큰.

두 가지를 활용해서 아래와 같이 구현한다.

  1. access token 은 프론트엔드에서 자바스크립트로 접근할 수 있게 설정. 또한 혹시나 access token 이 탈취되더라도 문제가 없도록, 일부러 access token 의 만료 시간을 짧게 설정합니다. 해당 토큰이 만료되면, refresh token 을 활용해서 재발급 받도록 한다.
  2. refresh token 은 프론트엔드에서 자바스크립트로 접근할 수 없도록 (탈취당할 수 없도록) httpOnly 쿠키로 설정. (백엔드에서 쿠키를 설정하는 응답을 내려줌) refresh token 은 만료 시간을 길게 설정해서, access token 이 만료될 경우 해당 토큰을 재발급받을 수 있도록 한다.

2) json-server-auth 사용하기

jwt를 한번 사용해보자.
json server auth를 이용해서 jwt기능이 구현된 백엔드를 실행해보자.

yarn add json-server-auth

  • package.json수정
"scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
    "server": "json-server-auth --watch db.json --port 8000"
  },
  • db.json 수정
{
    "posts": [
        {
            "title": "First Post",
            "body": "First Content",
            "id": 1
        }
    ],
    "users": []
}

이제 yarn server로 서버를 실행하고

서버주소/register 로 email, password 를 담아서 요청을 보내면 회원가입이 진행된다.

서버주소/login 으로 email, password 를 담아서 요청을 보내면 로그인이 진행된다.

요청이 성공하면, accessToken 이라는 곳에 jwt token 을 담아서 전달해준다.

이제 앞으로 요청보낼 때 **해당 token을 header 라는 곳에 담아서 요청**을 보내면,

백엔드에서는 이 **token 만으로 로그인 여부 식별 / 어떤 유저인지 식별**을 할 수 있게 되는 것이다

3) router 구현

import { BrowserRouter, Link, Route, Routes } from 'react-router-dom';
import Login from './components/Login';
import Register from './components/Register';
import Posts from './components/Posts';

function App() {
  return (
    <BrowserRouter>
      <Link to="/login">Login</Link> |
      <Link to="/post">Post</Link> |
      <Link to="/register">Register</Link>
      <Routes>
        <Route path="/login" element={<Login/>}/>
        <Route path="/post" element={<Posts/>}/>
        <Route path="/register" element={<Register/>}/>
      </Routes>
    </BrowserRouter>
  );
}

export default App;

4) 회원가입 구현 ( 토큰 투키에 저장)

아래처럼 회원가입 컴포넌트를 구현한다.

import axios from 'axios'
import React, { useState } from 'react'
import {useNavigate} from 'react-router-dom'

function Register() {
  const [userInput, setUserInput] = useState({email:'', password:''})
  const inputChangeHandler = (e) => {
    const { name, value } = e.target
    setUserInput({...userInput, [name]:value})
  }

  const doSignup = async () => {
    try {
        const response = await axios.post('http://localhost:8000/register', userInput)
        console.log(response.data)
    } catch (e) {
        console.log('error')
    }
  }

  return (
    <div>
        Register
        <input onChange={inputChangeHandler} name="email"></input>
        <input onChange={inputChangeHandler} name="password" type="password"></input>
        <button onClick={doSignup}>회원가입</button>
    </div>
  )
}

export default Register

response.data 에 accessToken 이 보입니다! 이제 이걸 쿠키로 저장한다. 쿠키로 저장을 해놓으면, 유저가 새로고침을 해도, 웹브라우저를 종료한 후에 다시 홈페이지에 접속을 해도, 계속 token 이 저장되어있기 때문에 언제든지 로그인이 되어있게 된다.

yarn add react-cookie

그리고 쿠기와 관련된 설정을 추가한다

import axios from 'axios'
import React, { useState } from 'react'
import {useCookies} from 'react-cookie';
import {useNavigate} from 'react-router-dom'

function Register() {
  const [userInput, setUserInput] = useState({email:'', password:''})
  const inputChangeHandler = (e) => {
    const { name, value } = e.target
    setUserInput({...userInput, [name]:value})
  }
  const [cookies, setCookie, removeCookie] = useCookies()
  const navigate = useNavigate()

  const doSignup = async () => {
    try {
        const response = await axios.post('http://localhost:8000/register', userInput)
        setCookie('accessToken', response.data['accessToken'], {path:'/'})
        // 백엔드가 진짜라면 userId 쿠키에 필요없음, 어차피 accessToken 에 담겨있음
        setCookie('userId', response.data['user']['id'], {path:'/'})
        navigate('/post')
    } catch (e) {
        console.log('error')
    }
  }

  return (
    <div>
        Register
        <input onChange={inputChangeHandler} name="email"></input>
        <input onChange={inputChangeHandler} name="password" type="password"></input>
        <button onClick={doSignup}>회원가입</button>
    </div>
  )
}

export default Register

이제 input에 이메일과 비밀번호를 입력하고 회원가입 버튼을 누르면 posts 페이지로 이동하게 된다.

5) 로그인 구현하기

회원가입과 비슷한 느낌으로 구현하면 된다.

import axios from 'axios'
import React, { useState } from 'react'
import {useCookies} from 'react-cookie';
import {useNavigate} from 'react-router-dom'

function Login() {
  const [userInput, setUserInput] = useState({email:'', password:''})
  const inputChangeHandler = (e) => {
    const { name, value } = e.target
    setUserInput({...userInput, [name]:value})
  }
  const [cookies, setCookie, removeCookie] = useCookies()
  const navigate = useNavigate()

  const doLogin = async () => {
    try {
      const response = await axios.post('http://localhost:8000/login', userInput)
      setCookie('accessToken', response.data['accessToken'], {path:'/'})
      // 백엔드가 진짜라면 userId 쿠키에 필요없음, 어차피 accessToken 에 담겨있음
      setCookie('userId', response.data['user']['id'], {path:'/'})
      navigate('/post')
    } catch (e) {
      console.log('error')
    }
  }

  return (
    <div>
        Login
        <input onChange={inputChangeHandler} name="email"></input>
        <input onChange={inputChangeHandler} name="password" type="password"></input>
        <button onClick={doLogin}>로그인</button>
    </div>
  )
}

export default Login

그럼이제 회원가입 했던 이메일과 비밀번호를 입력하면 바로 posts 로 이동하게 된다.

6) 토큰을 불러와서 요청보내기

토큰을 불러오는건 cookies 에서 불러오면된다.

그렇게 하고 아래와 같이 axios 요청을 보낼때 2번째 인자로 headers를 추가해주면, 요청을 보낼때 tooken이 포함되어 함께 전송된다.

7) 내가 쓴 글만 수정/삭제가 가능하도록 하기

실제 백엔드와 통신

1) redux 설정

yarn create vite (앱명) --template react

redux 관련 모듈 설치

yarn add @reduxjs/toolkit react-redux axios react-cookie

  • axios 설정

.env

VITE_SERVER_URL='http://localhost:3000/'

apis/axios.js

import axios from 'axios';
import { getCookie } from '../utils/cookies';

const getAxiosInstance = (url) => {
  const instance = axios.create({
    baseURL: url,
    headers: { // 필요한 경우 header 도 명시
      'Content-type': 'application/json',
    },
    withCredentials: true // 백엔드에서 쿠키를 설정해줄 수 있도록 명시
  });
  instance.defaults.timeout = 3000;
  // token 의 경우 요청을 보낼 때마다 현재 토큰 값을 가져오도록 작성
  // (create 할때 넣어줄 경우 고정값으로 들어가게 되므로, 아래처럼 작성하는 방식이 타당함)
  instance.interceptors.request.use(
    (request) => {
      const token = getCookie('accessToken')
      if (token) request.headers['Authorization'] = `Bearer ${token}`
      return request
    },
    (error) => {
      console.log(error)
      return Promise.reject(error)
    }
  )
  return instance
}

export const axiosInstance = getAxiosInstance(import.meta.env.VITE_SERVER_URL)

로그인, 회원가입이 구현된 상태라면, 요청을 보낼 때 토큰값도 함께 보내주어야 합니다. (그렇게 해야만 백엔드에서 인증 여부를 확인할 수 있다.)

그렇기 때문에 instance 를 만들면서, 인터셉터 형태로 요청을 보낼 때마다 쿠키에 저장된 토큰을 가져와서 보내도록 작성을 해주겠습니다. (또한 withCredentials: true 로 설정해서 백엔드에서 쿠키를 설정할 수 있도록 해준다.)

앞으로는 위에서 작성한 axiosInstance 를 활용해서 요청을 보낼 예정이다.

  • redux slice 및 store 설정

store/slices/postSlice.js

import { createAsyncThunk } from '@reduxjs/toolkit'
import { axiosInstance } from './../../apis/axios'

// 초기값
const initialState = {
    entities: [],
    loading: false,
}

// async thunk 생성
// rejectWithValue를 통해서 에러 처리

export const getPosts = createAsyncThunk('posts/getPosts', async (_, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.get('api/posts')
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})

export const getPost = createAsyncThunk('posts/getPost', async (postId, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.get(`api/posts/${postId}`)
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})

export const createPost = createAsyncThunk('posts/createPost', async (post, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.post(`api/posts`, post)
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})

export const postSlice = createSlice({
    name: 'posts',
    initialState,
    reducers: {},
    extraReducers: {
        [getPosts.pending]: (state) => {
            state.loading = true
        },
        [getPosts.fulfilled]: (state, action) => {
            state.loading = false
            state.entities = action.payload
        },
        [getPosts.rejected]: (state) => {
            state.loading = false
        },
        [getPost.pending]: (state) => {
            state.loading = true
        },
        [getPost.fulfilled]: (state, action) => {
            state.loading = false
            if (state.entities.find((entity) => entity, id === action.payload.id)) {
                state.entites = state.entites.map((entity) => (entity.id === action.payload.id ? action.payload : entity))
            } else {
                state.entites = [...state.entites, action.payload]
            }
        },
        [getPost.rejected]: (state) => {
            state.loading = false
        },
        [createPost.pending]: (state) => {
            state.loading = true
        },
        [createPost.fulfilled]: (state, action) => {
            state.loading = false
            state.entities = [...state.entities, action.payload]
        },
        [createPost.rejected]: (state) => {
            state.loading = false
        },
    },
})

export default postSlice.reducer

store/slice/authSlice.js

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { axiosInstance } from '../../apis/axios'
import { setCookie } from '../../utils/cookies'

const initialState = {
    loading: false,
    userInfo: {},
    isAuthenticated: 'PENDING',
}

export const login = createAsyncThunk('auth/login', async (user, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.post('api/auth/login', user)
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})
export const register = createAsyncThunk('auth/register', async (user, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.post('api/auth/register', user)
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})
export const verify = createAsyncThunk('auth/verify', async (_, { rejectWithValue }) => {
    try {
        const response = await axiosInstance.get('api/auth/verify')
        return response.data
    } catch (err) {
        return rejectWithValue(err.response.data)
    }
})

export const authSlice = createSlice({
    name: 'auth',
    initialState,
    reducers: {},
    extraReducers: {
        [login.pending]: (state) => {
            state.loading = true
            // cookie 와 함께 isAuthenticated 값을 통해서 유저의 인증 여부를 판별
            // cookie 에 유효하지 않은 토큰이 들어가있는데도 인증되었다고 판단하면 안되므로,
            // verify 요청 등을 활용해서 유효한 토큰인지 여부를 판단하고 인증 여부를 판단
            state.isAuthenticated = 'PENDING'
        },
        [login.fulfilled]: (state, action) => {
            // asyncthunk 함수 안에서 if 문을 통해서 setCookie 해도 무방
            setCookie('accessToken', action.payload.accessToken, { path: '/', maxAge: action.payload.exp - action.payload.iat })
            state.loading = false
            // nav 바에 표시를 위해서 저장
            state.userInfo = { userId: action.payload.id, username: action.payload.username }
            state.isAuthenticated = 'SUCCESS'
        },
        [login.rejected]: (state) => {
            state.loading = false
            state.isAuthenticated = 'FAILED'
        },
        [register.pending]: (state) => {
            state.loading = true
            state.isAuthenticated = 'PENDING'
        },
        [register.fulfilled]: (state, action) => {
            setCookie('accessToken', action.payload.accessToken, { path: '/', maxAge: action.payload.exp - action.payload.iat })
            state.loading = false
            state.userInfo = { userId: action.payload.id, username: action.payload.username }
            state.isAuthenticated = 'SUCCESS'
        },
        [register.rejected]: (state) => {
            state.loading = false
            state.isAuthenticated = 'FAILED'
        },
        [verify.pending]: (state) => {
            state.loading = true
            state.isAuthenticated = 'PENDING'
        },
        [verify.fulfilled]: (state, action) => {
            state.loading = false
            state.userInfo = { userId: action.payload.id, username: action.payload.username }
            state.isAuthenticated = 'SUCCESS'
        },
        [verify.rejected]: (state) => {
            state.loading = false
            state.userInfo = {}
            state.isAuthenticated = 'FAILED'
        },
    },
})

export default authSlice.reducer

utils/cookies.js

import { Cookies } from 'react-cookie'

const cookies = new Cookies() // 쿠키 객체를 생성하고,

export const getCookie = (name) => {
    try {
        return cookies.get(name) // get 을 하면 name 을 기준으로 쿠키 값을 가져옵니다.
    } catch (error) {
        console.error(error)
    }
}

export const setCookie = (name, value, option) => {
    try {
        cookies.set(name, value, { ...option }) // set 을 하면 name = value 형태로 쿠키를 설정할 수 있습니다.
    } catch (error) {
        console.error(error)
    }
}

export const removeCookie = (name, option) => {
    try {
        cookies.remove(name, { ...option }) // remove 하면 해당 name 쿠키를 삭제합니다.
    } catch (error) {
        console.error(error)
    }
}

store/index.js

import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { authSlice } from './slices/authSlice'
import postReducer from './slices/postSlice'
import authReducer from './slices/authSlice'
import { postSlice } from './slices/postSlice'

const rootReducer = combineReducers({
    [authSlice.name]: authReducer,
    [postSlice.name]: postReducer,
})

const store = configureStore({
    reducer: rootReducer,
    middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
})

export default store

app.jsx

import './App.css'
import { Provider} from 'react-redux'
import store from './store/index'


function App() {
    return (
        <Provider store={store}>
           <></>
        </Provider>
    )
}

export default App

3) component 생성하기

작은 단위로 컴포넌트를 생성하고, 해당 컴포넌트를 이용해서 페이지를 구성한다.

  • common -> layout, nav 컴포넌트

  • login -> loginform 컴포넌트 (formik,tup 활용)

  • posts -> postform, postitem, postlist 컴포넌트

  • register -> registerform 컴포넌트

  • common 컴포넌트

common/Layout/index.jsx

import React from 'react'
import { useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom'
import Nav from '../Nav'

function Layout() {
    const username = useSelector((state) => state.auth.userInfo.username)

    return (
        <>
            {username ? <p>{username}님 안녕하세요</p> : null}
            <Nav />
            <Outlet />
            <footer>푸터입니다</footer>
        </>
    )
}

export default Layout

common/Nav/index.jsx

import React from 'react'
import { NavLink } from 'react-router-dom'

function Nav() {
    return (
        <nav>
            <NavLink style={({ isActive }) => ({ color: isActive ? 'red' : 'black' })} to="/posts">
                Posts
            </NavLink>{' '}
            <NavLink style={({ isActive }) => ({ color: isActive ? 'red' : 'black' })} to="/login">
                Login
            </NavLink>{' '}
            <NavLink style={({ isActive }) => ({ color: isActive ? 'red' : 'black' })} to="/register">
                Register
            </NavLink>
        </nav>
    )
}

export default Nav
  • login 컴포넌트

login/LoginForm/index.jsx

import React, { useState } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'

function LoginForm({ onSubmit }) {
    const formik = useFormik({
        initialValues: {
            username: '',
            password: '',
        },
        validationSchema: Yup.object({
            username: Yup.string().required('아이디를 입력해주세요'),
            password: Yup.string().required('비밀번호를 입력해주세요'),
        }),
        onSubmit: (values) => {
            onSubmit(values) //
        },
    })
    // getFiledProps 를 활용하면 value, onChange, onBllur, name 등의 props들이 모두 태그에 들어가게된다.

    return (
        <form onSubmit={formik.handleSubmit}>
            <input name="username" {...formik.getFieldProps('username')} />
            {formik.errors.username ? <p>{formik.errors.username}</p> : null}
            <input name="password" {...formik.getFieldProps('password')} />
            {formik.errors.password ? <p>{formik.errors.password}</p> : null}
            <button type="submit">제출하기</button>
        </form>
    )
}

export default LoginForm

Yup은 유효성 검증을 위한 라이브러리이며, Formik는 리액트에서 form을 더욱 원활하게 사용할수 있도록 도와주는 라이브러리이다. 위처럼 Yup객체를 만들어서 FormikvalidationSchema로 할당하면, 해당 객체 key 와 일치하는 name을 가진 input에 대해서 유효성 검증을 하게 된다.

우선 string,number,email,url,date,required,min,max 등의 속성 지정이 가능하다.

자세한 사항은 https://github.com/jquense/yup#yup 이곳을 참조

  • register 컴포넌트
    register/RegisterForm/index.jsx
import { useFormik } from 'formik'
import React from 'react'

function RegisterForm() {
    const formik = useFormik({
        initialValues: {
            email: '',
            username: '',
            password: '',
        },
        validationSchema: Yup.object({
            email: Yup.string().email('올바른 형식으로 입력해주세요').required('이메일을 입력해주세요'),
            username: Yup.string().required('아이디를 입력해주세요'),
            password: Yup.string().min(8, '최소 8자 이상은 작성해주세요').required('비밀번호를 입력해주세요'),
        }),
        onSubmit: (values) => {
            onSubmit(values)
        },
    })
    return (
        <form onSubmit={formik.handleSubmit}>
            <input name="email" {...formik.getFieldProps('email')} />
            {formik.errors.email ? <p>{formik.errors.email}</p> : null}
            <input name="username" {...formik.getFieldProps('username')} />
            {formik.errors.username ? <p>{formik.errors.username}</p> : null}
            <input name="password" {...formik.getFieldProps('password')} />
            {formik.errors.password ? <p>{formik.errors.password}</p> : null}
            <button type="submit">제출하기</button>
        </form>
    )
}

export default RegisterForm
  • posts 컴포넌트
    post/PostList/index.jsx
import React from 'react'
import { Link } from 'react-router-dom'

function PostList({ posts }) {
    if (!posts) return <p>Not Found</p>
    return (
        <div>
            {posts.map((post) => {
                ;<p key={post.id}>
                    <Link to={`/posts/${post.id}`}>{post.title}</Link>
                </p>
            })}
        </div>
    )
}

export default PostList

posts/PostItem/index.jsx

import React from 'react'

function PostItem({ post }) {
    if (!post) return <p>Not Found</p>
    return (
        <div>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
        </div>
    )
}

export default PostItem

Posts/PostForm/index.jsx

import { useFormik } from 'formik'
import React from 'react'
import * as Yup from 'yup'

function PostForm({ onSubmit }) {
    const formik = useFormik({
        initialValues: {
            title: '',
            body: '',
        },
        validationSchema: Yup.object({
            title: Yup.string().min(10, '최소 10자 이상 작성해주세요').required('제목을 입력해주세요'),
            body: Yup.string().min(10, '최소 10자 이상 작성해주세요').required('내용을 입력해주세요'),
        }),
        onSubmit: (values) => {
            onSubmit(values)
        },
    })

    return (
        <form onSubmit={formik.handleSubmit}>
            <input name="title" {...formik.getFieldProps('title')} />
            {formik.errors.title ? <p>{formik.errors.title}</p> : null}
            <input name="body" {...formik.getFieldProps('body')} />
            {formik.errors.body ? <p>{formik.errors.body}</p> : null}
            <button type="submit">제출하기</button>
        </form>
    )
}

export default PostForm

4) 페이지 설정하기

page와 route는 아래와 같이 구성한다.

  • /login -> 로그인 페이지
  • /registre -> 회원가입 페이지
  • /posts -> 글 리스트
  • /posts/:id -> 글 하나
  • /posts/new -> 글 작성

pages/Login/index.jsx

import React from 'react'
import { useDispatch } from 'react-redux'
import LoginForm from '../../components/login/LoginForm'
import { login } from './../../store/slices/authSlice'

function Login() {
    const dispatch = useDispatch()
    const onSubmit = (user) => {
        dispatch(login(user))
    }
    return (
        <div>
            <LoginForm onSubmit={onSubmit} />
        </div>
    )
}

export default Login

pages/Register/index.jsx

import React from 'react'
import { useDispatch } from 'react-redux'
import RegisterForm from '../../components/register/RegisterForm'
import { register } from '../../store/slices/authSlice'

function Register() {
    const dispatch = useDispatch()
    const onSubmit = (user) => {
        dispatch(register(user))
    }
    return <RegisterForm onSubmit={onSubmit} />
}

export default Register

pages/Posts/index.jsx

import React from 'react'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import PostList from '../../components/posts/PostList'
import { getPosts } from './../../store/slices/postSlice'

function Posts() {
    const dispatch = useDispatch()
    const { entities: posts, loading } = useSelector((state) => state.posts)

    useEffect(() => {
        dispatch(getPosts())
    }, [])
    if (loading) return <p>Loading</p>
    return (
        <div>
            <PostList posts={posts} />
        </div>
    )
}

export default Posts

pages/Posts/PostDetail/index.jsx

import React from 'react'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useParams } from 'react-router-dom'
import PostItem from '../../../components/posts/PostItem'
import { getPosts } from '../../../store/slices/postSlice'

function PostDetail() {
    const { postId } = useParams()
    const dispatch = useDispatch()
    const navigate = useNavigate()
    const { post, loading } = useSelector((state) => ({
        post: state.posts.entities.find((entity) => entity.id === Number(postId)),
        loading: state.posts.loading,
    }))
    useEffect(() => {
        if (!post) dispatch(getPosts(postId))
    }, [])
    if (loading) return <p>Loading...</p>
    return (
        <div>
            <PostItem post={post} />
            <button onClick={() => navigate(-1)}>목록으로</button>
        </div>
    )
}

export default PostDetail

pages/Posts/PostCreate/index.jsx

import React from 'react'
import { useDispatch } from 'react-redux'
import PostForm from '../../../components/Posts/PostForm'
import { createPost } from '../../../store/slices/postSlice'

function PostCreate() {
    const dispatch = useDispatch()
    const onSubmit = (post) => {
        dispatch(createPost(post))
    }

    return (
        <div>
            <PostForm onSubmit={onSubmit} />
        </div>
    )
}

export default PostCreate

Routes/router

import { Routes, Route } from 'react-router-dom'
import PostCreate from '../pages/Posts/PostCreate'

function Router() {
    return (
        <Routes>
            <Route path="/" element={<Layout />}>
                <Route path="posts" element={<Posts />} />
                <Route path="posts/:postId" element={<PostDetail />} />
                <Route path="posts/new" element={<PostCreate />} />
                <Route path="login" element={<Login />} />
                <Route path="register" element={<Register />} />
                <Route path="*" element={<p>not Found</p>} />
            </Route>
        </Routes>
    )
}

export default Router

5) token 검증 관련 추가 설정

  • 토큰 검증은 매우 중요하다. 단순히 토큰이 존재한다고 해서 특정한 페이지에 접근이 가능하도록 한다면, 보안상의 문제가 생길수 있다.

그렇기 때문에 현재 유저의 토큰이 진짜 유효한 토큰인지 ( 우리가 발급해준 토큰이 맞는지, 유효기간이 지난건 아닌지 )
토큰 검증을 위한 훅(verifyToken)을 만들어서 사용하도록 하자

hooks/verifyToken.jsx

import React from 'react'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { getCookie } from '../../utils/cookies'

// 토큰 만료와 갱신을 구현하고자 한다면, verifyToken을 페이지마다 실행해서 체크하는 것이 중요!
function verifyToken(key) {
    const dispatch = useDispatch()
    const token = getCookie('accessToken')
    const { isAuthenticated } = useSelector((state) => state.auth)

    useEffect(() => {
        if (token) {
            dispatch(verify())
        }
        // 추가로 유효 기간 검사, 만료 시 refresh 요청 등의 로직 구현
    }, [token, key])

    return isAuthenticated
}

export default verifyToken

이제 토큰 검증을 위한 훅을 최상단 route(현재는 Layout) 에서 실행하도록 해서 , 어던 페이지로 가더라도 해당 로직이 실행되도록 작성한다.

components/common/Layout/index.jsx

import React from 'react'
import { useSelector } from 'react-redux'
import { Outlet, useLocation } from 'react-router-dom'
import verifyToken from '../../hooks/verifyToken'
import Nav from '../Nav'

function Layout() {
    const loacation = useLocation()
    const username = useSelector((state) => state.auth.userInfo.username)
    // 최상위 route에 verifyToken을 넣어서, 어떤 페이지에서든 실행되도록 설정
    const isAuthenticated = verifyToken(loacation.key) // key 는 페이지 이동마다 달라짐

    return (
        <>
            {username ? <p>{username}님 안녕하세요</p> : null}
            <Nav />
            <Outlet context={{ isAuthenticated }} />
            <footer>푸터입니다</footer>
        </>
    )
}

export default Layout

특이한 점은 Outlet 에 context 형태로 유저의 인증 여부를 넘겨주도록 작성했음

이제 가져온 isAuthenticated값을 가지고, 페이지 접근 가능 여부를 판단하는 로직을 ProtectedRouter에 작성한다.

import React from 'react'
import { useEffect } from 'react'
import { Outlet, useNavigate, useOutletContext } from 'react-router-dom'
import { getCookie } from './../utils/cookies'

function ProtectedRouter() {
    const navigate = useNavigate()
    const token = getCookie('accessToken')
    //layout 쪽에서 받아와서 사용
    // 만약 layout 쪽에서 굳이 verifyToken 할 필요가 없다면 여기서만 해도 무방
    const { isAuthenticated } = useOutletContext()

    useEffect(() => {
        if (!token) {
            //토큰 없으면 바로 홈화면으로
            alert('로그인을 해주세요')
            navigate('/')
        } else if (isAuthenticated === 'FAILED') {
            // 토큰이 있다면 검증여부에 따라 홈화면으로
            alert('잘못된 접근입니다.')
            navigate('/')
        }
    }, [token, isAuthenticated])
    return (
        <div>
            <Outlet />
        </div>
    )
}

export default ProtectedRouter

이제 이 Router로 감싸는 페이지는 항상 토큰 및 isAuthenticated를 토대로 유효한 토큰이 있는 경우에만 접근할수 있다.

routes/Router.jsx

import { Routes, Route } from 'react-router-dom'
import Layout from '../components/common/Layout'
import Login from '../pages/Login'
import Posts from '../pages/Posts'
import PostCreate from '../pages/Posts/PostCreate'
import PostDetail from '../pages/Posts/PostDetail'
import Register from '../pages/Register'
import ProtectedRouter from './ProtectedRouter'

function Router() {
    return (
        <Routes>
            <Route path="/" element={<Layout />}>
                <Route path="posts" element={<Posts />} />
                <Route path="posts/:postId" element={<PostDetail />} />
                <Route element={<ProtectedRouter />}>
                    <Route path="posts/new" element={<PostCreate />} />
                </Route>
                <Route path="login" element={<Login />} />
                <Route path="register" element={<Register />} />
                <Route path="*" element={<p>not Found</p>} />
            </Route>
        </Routes>
    )
}

export default Router

이제 PostCreate는 로그인을 해서 유효한 토큰이 쿠키에 있는 경우에만 접근할수 있다.

6) token refresh 설정

만약 access token 이 만료되어서 없고, refresh token 만 쿠키에 존재하는 상화이라면?

그때는 자동으로 refresh요청을 모재고 access token을 재발급 받도록 해줘야한다.

우선 refresh 요청을 보낼수 있게 authSlice를 수정한다.
refresh token 이 존재한느지 여부를 저장하기 위한 isRefeshExists라는 플래그를 하나 추가한다.

refresh token은 httpOnly 쿠키이기 때문에 프론트에서 존재여부를 확인할수 없고, 존재 여부는 401응답이 오는지로 판별한다. refresh를 하는 방식은 플래그를 활용하는 방식 외에도 다양한 방식이 존재한다.

store/slices/authSlice.js

// 하위 내용 추가
   [refresh.pending]: (state) => {
            state.loading = true
            state.isAuthenticated = 'PENDING'
            state.isRefreshExists = false
        },
        [refresh.fulfilled]: (state, action) => {
            setCookie('accessToken', action.payload.accessToken, { path: '/', maxAge: action.payload.exp - action.payload.iat })
            state.loading = false
            state.userInfo = { userId: action.payload.id, username: action.payload.username }
            state.isAuthenticated = 'SUCCESS'
            state.isRefreshExists = true
        },
        [refresh.rejected]: (state) => {
            state.loading = false
            state.userInfo = {}
            state.isAuthenticated = 'FAILED'
            state.isRefreshExists = true
        },

이제 verifyToken에다가 refresh와 관련한 로직을 추가한다.

profile
개발자 꿈나무

0개의 댓글