공개 라우트와 비공개 라우트
홈페이지(/), 로그인 페이지(/login) 등 누구나 접속할 수 있는 경로를 공개 라우트(public route)라고하며 로그인한 사용자(권한이 있는)만 접속할 수 있는 경로는 비공개 라우트(private route)라고 한다.
사용자 인증 컨텍스트 만들기
리액트 프레임워크에서 여러 컴포넌트가 어떤 정보를 공유하는건 컨텍스트, 리덕스 등의 방법이 있다. 리덕스의 경우 앱이 항상 사용한다고 가정하기 어렵기에 컨텍스트를 사용해 사용자의 로그인 유무를 구분하는 방법에 대해 준비하자.
우선 인증 컨텍스트를 AuthContext로 추가한다.
import type {FC, PropsWithChildren} from 'react'
import {createContext, useContext, useState, useCallback} from 'react'
export type LoggedUser = {email: string; password: string}
type Callback = () => void
type ContextType = {
loggedUser?: LoggedUser
signUp: (email: string, password: string, callback?: Callback) => void
login: (email: string, password: string, callback?: Callback) => void
logout: (callback?: Callback) => void
}
export const AuthContext = createContext<ContextType>({
signUp(email: string, password: string, callback?: Callback) {},
login(email: string, password: string, callback?: Callback) {},
logout(callback?: Callback) {}
})
type AuthProviderProps = {}
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({children}) => {
const [loggedUser, setLoggedUser] = useState<LoggedUser | undefined>(undefined)
const signUp = useCallback((email: string, password: string, callback?: Callback) => {
setLoggedUser(notUsed => ({email, password}))
callback && callback()
}, [])
const login = useCallback((email: string, password: string, callback?: Callback) => {
setLoggedUser(notUsed => ({email, password}))
callback && callback()
}, [])
const logout = useCallback((callback?: Callback) => {
setLoggedUser(undefined)
callback && callback()
}, [])
const value = {
loggedUser,
signUp,
login,
logout
}
return <AuthContext.Provider value={value} children={children} />
}
export const useAuth = () => {
return useContext(AuthContext)
}
컨텍스트의 경우 AuthProvider를 통해 실제 공유될 컨텍스트 영역을 지정해야 하므로 App를 수정한다.
import {Provider as ReduxProvider} from 'react-redux'
import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import {BrowserRouter} from 'react-router-dom'
import {AuthProvider} from './contexts'
import RoutesSetup from './routes/RoutesSetup'
import {useStore} from './store'
import Board from './pages/Board'
export default function App() {
const store = useStore()
return (
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<BrowserRouter>
<AuthProvider>
<RoutesSetup />
</AuthProvider>
</BrowserRouter>
</DndProvider>
</ReduxProvider>
)
}
로그인 여부에 따라 네비게이션 메뉴 구분하기
사이트에서 LandingPage, Board는 Layout 컴포넌트 내부에 outlet에 나타나게 하지만 signUp 등은 Outlet 밖에 선언한다. 이유는 SignUp, login, logout 등은 컴포넌트를 표시할 때 네비게이션 메뉴가 나타나지 않는 게 바람직하기 때문이다.
그리고 signUp, login의 경우 선택시에 메뉴 선택시 daisyui 컴포넌트의 버튼 활성화를 할 필요가 없기에 react-router-dom을 그대로 사용하기 위해 별칭 RRLink로 변경해 코드를 수정한다.
마지막으로 앞서 구현한 useAuth 커스텀 훅을 통해 loggedUser 객체를 얻어와 로그인 여부에 따라 컴포넌트를 활성화 여부를 설정해 화면을 적절히 표시한다.
import {Link as RRLink} from 'react-router-dom'
import {Link} from '../../components'
import {useAuth} from '../../contexts'
export default function NavigationBar() {
const {loggedUser} = useAuth()
return (
<div className="flex justify-between bg-base-100">
<div className="flex p-2 navbar bg-base-100">
<Link to="/">Home</Link>
{loggedUser && (
<Link to="/board" className="ml-4">
Board
</Link>
)}
</div>
<div className="flex items-center p-2">
{!loggedUser && (
<>
<RRLink to="/login" className="btn btn-sm btn-primary">
Login
</RRLink>
<RRLink to="/signup" className="ml-4 btn btn-sm btn-outline btn-primary">
Signup
</RRLink>
</>
)}
{loggedUser && (
<RRLink to="/logout" className="ml-4 mr-4">
LOGOUT
</RRLink>
)}
</div>
</div>
)
}
회원 가입 기능 만들기
회원가입 컴포넌트를 만듬에 있어서 여러 고려사항에 대해서 미리 확인한다. 회원 상태에는 email, password, confirmPassword가 있을 수 있고 각각의 상태에는 useState를 변경할 이벤트를 각각 구성해야 하는 번거로움이 있다.
이럴 경우 상태에 대해서는 Record를 이용해 각각의 상태를 Record에 해당 속성을 추가해 사용해 만들면 수월하다.
// 불편한 예시
const [email, setEmail] useState<string>(D.randomEmail())
const [password, setPassword] = useState<string>('1')
const [confirmPassword, setConfirmPassword] = useState<string>('1')
// Record를 이용한 상태값 설정
type SignType = Record<'email' | 'password' | 'confirmPassword', string>
const [{email, password, confirmPassword}, setForm] = useState<SignFormType(initialFormState)
그리고 이벤트를 설정할 때에도 changed라는 2차 고차함수를 사용해 상태값을 간편하게 갱신할 수 있다.
// 불편한 예시
const emailChanged = usecallback((e: ChangeEvent<HTMLInputElement>) => {
setEmail(notUsed => e.target.value)
}, [])
const passwordChanged = usecallback((e: ChangeEvent<HTMLInputElement>) => {
setPassword(notUsed => e.target.value)
}, [])
const confirmPasswordChanged = usecallback((e: ChangeEvent<HTMLInputElement>) => {
setConfirmPassword(notUsed => e.target.value)
}, [])
// 2차 고차함수를 활용해 각각의 상태를 변경
const changed = useCallback((key: string) => (e: ChangeEvent<HTMLInputElement>) => {
setForm(obj => ({...obj, [key] : e.target.value}))
},[])
위와 같이 변경 시 <input>의 value와 onChange 속성값을 매우 간결하게 구현할 수 있다.
<input value={email} onChange={changed('email')} />
이를 토대로 회원가입 페이지인 signUp 컴포넌트를 추가하자.
import type {ChangeEvent} from 'react'
import {useState, useCallback} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import {useAuth} from '../../contexts'
import * as D from '../../data'
type SignupFromType = Record<'email' | 'password' | 'confirmPassword', string>
const initialFormState = {email: D.randomEmail(), password: '1', confirmPassword: '1'}
export default function Signup() {
const [{email, password, confirmPassword}, setForm] =
useState<SignupFromType>(initialFormState)
const changed = useCallback(
(key: string) => (e: ChangeEvent<HTMLInputElement>) => {
setForm(obj => ({...obj, [key]: e.target.value}))
},
[]
)
const navigate = useNavigate()
const {signup} = useAuth()
const createAccount = useCallback(() => {
console.log(email, password, confirmPassword)
if (password === confirmPassword) {
signup(email, password, () => navigate('/'))
}
}, [email, password, confirmPassword, signup])
// prettier-ignore
return (
<div className="flex flex-col min-h-screen bg-gray-100 border border-gray-300 shadow-xl rounded-xl">
<div className="flex flex-col items-center justify-center flex-1 max-w-sm px-2 mx-auto">
<div className="w-full px-6 py-8 text-black bg-white rounded shadow-md">
<h1 className="mb-8 text-2xl text-center text-primary">Sign up</h1>
<input type="text" className="w-full p-3 mb-4 input input-primary" name="email"
placeholder="Email" value={email} onChange={changed('email')}/>
<input type="password" className="w-full p-3 mb-4 input input-primary" name="password"
placeholder="password" value={password} onChange={changed('password')} />
<input type="password" className="w-full p-3 mb-4 input input-primary" name="confirm_password"
placeholder="Confirm Password" value={confirmPassword} onChange={changed('confirmPassword')} />
<button type="submit" className="w-full btn btn-primary" onClick={createAccount}>Create Account</button>
</div>
<div className="mt-6 text-grey-dark">
Already have an account?
<Link className="btn btn-link btn-primary" to={'/login/'}>
Login
</Link>
</div>
</div>
</div>
)
}

웹 브라우저를 종료해도 지워지지 않는 저장소 이용하기(LocalStorage)
자바스크립트 엔진은 window.localStorage 객체를 기본으로 제공하며 이 객체는 웹 브라우저가 접속한 웹 사이트별로 데이터를 저장할 수 있는 공간을 제공한다.
localStorage는 getItem과 setItem 메서드를 제공해 저장공간의 데이터를(키,값)형태로 저장하고 저장된 값을 읽을 수 있다. 다만 이 두 메서드는 예외적인 오류를 일으켜 프로그램이 종료될 수 있다. 이 문제를 예방하고자 localStorageP, readWriteObjectP utils을 추가하자.
localStorageP는 localStorage에 값을 저장/읽기가 가능하도록 구현한다. read의 타입은 key의 값이 없을 경우 null을 반환해야 하므로 타입을 string | null로 설정한다.
export const readItemFormStorageP = (key: string) =>
new Promise<string | null>(async (resolve, reject) => {
try {
const value = localStorage.getItem(key)
resolve(value)
} catch (error) { reject(error) }
})
export const writeItemToStorageP = (key: string, value: string) =>
new Promise<string | null>(async (resolve, reject) => {
try {
localStorage.setItem(key, value)
resolve(value)
} catch (error) { reject(error) }
})
export const readStringP = readItemFormStorageP
export const writeStringP = writeItemToStorageP
localStorage 객체의 getItem, setItem은 모두 문자열 타입의 값을 다루므로 자바스크립트 객체 저장 시 JSON.stringify와 JSON.parse 함수를 호출해야하는 번거로움이 있어 이를 해소하고자 readWriteObjectP를 추가한다.
import * as L from './localStorageP'
export const readObjectP = <T extends object>(key: string) =>
new Promise<T | null>((resolve, reject) => {
L.readStringP(key)
.then(value => resolve(value ? JSON.parse(value) : null))
.catch(reject)
})
export const writeObjectP = (key: string, value: Object) =>
L.writeStringP(key, JSON.stringify(value))
앞서 구현한 writeObjectP함수를 사용해 localStorage에 사용자가 회원 가입시 입력한 정보를 저장하기 위해 AuthContext에 소스를 일부 변경한다.
import type {FC, PropsWithChildren} from 'react'
import {createContext, useContext, useState, useCallback} from 'react'
import * as U from '../utils' // 추가
...(생략)...
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({children}) => {
const [loggedUser, setLoggedUser] = useState<LoggedUser | undefined>(undefined)
const signup = useCallback((email: string, password: string, callback?: Callback) => {
const user = {email, password} // 추가
setLoggedUser(notUsed => ({email, password}))
U.writeObjectP('user', user).finally(() => callback && callback()) // 추가
// callback && callback()
}, [])
로그인 기능 만들기
로그인 컴포넌트는 회원가입 컴포넌트와 유사하다. 로그인도 일단 추가하자.
import type {ChangeEvent} from 'react'
import {useState, useCallback, useEffect} from 'react'
import {Link, useNavigate} from 'react-router-dom'
import {useAuth} from '../../contexts'
import * as D from '../../data'
import * as U from '../../utils'
type LoginFormType = Record<'email' | 'password', string>
const initialFormState = {email: D.randomEmail(), password: '1'}
export default function Login() {
const [{email, password}, setForm] = useState<LoginFormType>(initialFormState)
const changed = useCallback(
(key: string) => (e: ChangeEvent<HTMLInputElement>) => {
setForm(obj => ({...obj, [key]: e.target.value}))
},
[]
)
const navigate = useNavigate()
const {login} = useAuth()
const loginAccount = useCallback(() => {
debugger
login(email, password, () => navigate('/'))
}, [email, password, navigate, login])
useEffect(() => {
U.readObjectP<LoginFormType>('user')
.then(user => {
if (user) setForm(user)
})
.catch(e => {})
}, [])
return (
<div className="flex flex-col min-h-screen bg-gray-100 border border-gray-300 shadow-xl rounded-xl">
<div className="flex flex-col items-center justify-center flex-1 max-w-sm px-2 mx-auto">
<div className="w-full px-6 py-8 text-black bg-white rounded shadow-md">
<h1 className="mb-8 text-2xl text-center text-primary">Sign up</h1>
<input
type="text"
className="w-full p-3 mb-4 input input-primary"
name="email"
placeholder="Email"
value={email}
onChange={changed('email')}
/>
<input
type="password"
className="w-full p-3 mb-4 input input-primary"
name="password"
placeholder="password"
value={password}
onChange={changed('password')}
/>
<button type="submit" className="w-full btn btn-primary" onClick={loginAccount}>
Login Account
</button>
</div>
<div className="mt-6 text-grey-dark">
Create account?
<Link className="btn btn-link btn-primary" to={'/signup/'}>
SignUp
</Link>
</div>
</div>
</div>
)
}

로그아웃 기능 만들기
로그아웃 컴포넌트의 경우 daisyui 모달 컴포넌트를 사용해 사용자의 로그아웃 의사를 확인하는 대화상자를 표시하는 형태의 컴포넌트를 추가하자.
import {useCallback} from 'react'
import {useNavigate} from 'react-router-dom'
import {Modal, ModalContent, ModalAction} from '../../theme/daisyui'
import {useToggle} from '../../hook'
import {useAuth} from '../../contexts'
export default function Logout() {
const [open, toggleOpen] = useToggle(true)
const navigate = useNavigate()
const {logout} = useAuth()
const onAccept = useCallback(() => {
logout(() => {
toggleOpen()
navigate('/')
})
}, [navigate, toggleOpen, logout])
const onCancel = useCallback(() => {
toggleOpen()
navigate(-1)
}, [toggleOpen, navigate])
return (
<Modal open={open}>
<ModalContent
closeIconClassName="btn-primary btn-outline"
onCloseIconClicked={onCancel}>
<p className="text-xl text-center">Are you sure you want to logout?</p>
<ModalAction className="mt-4">
<button className="btn btn-primary btn-sm" onClick={onAccept}>
Logout
</button>
<button className="ml-4 btn btn-secondary btn-sm" onClick={onCancel}>
Cancel
</button>
</ModalAction>
</ModalContent>
</Modal>
)
}

로그인한 사용자만 접근하도록 막기
클라이언트 라우팅은 웹 브라우저의 주소 창을 이용해 url을 통한 직접 접근이 가능하다. 예를 들어 Board에는 로그인을 해야만 접근이 가능해야 하는데 이런 부분을 loggedUser값을 통해 체크해야 한다.
다만 이런작업을 각각의 컴포넌트에 구성하기에는 반복적인 코드의 불편함이 있다. 그러므로 RequireAuth 컴포넌트를 추가해 비공개 라우트에 설정된 컴포넌트마다 컨텍스트 형태로 예외처리를 진행할 수 있다.
import type {FC, PropsWithChildren} from 'react'
import {useEffect} from 'react'
import {useNavigate} from 'react-router-dom'
import {useAuth} from '../../contexts'
type RequireAuthProps = {}
const RequireAuth: FC<PropsWithChildren<RequireAuthProps>> = ({children}) => {
const {loggedUser} = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!loggedUser) navigate(-1) // 허가되지않은 사용자는 이전 페이지로 돌아감
}, [loggedUser, navigate])
return <>{children}</> // 허가된 사용자만 children이 element가 되도록 함
}
export default RequireAuth
추가한 RequireAuth 컴포넌트를 RoutesSetup에 컨텍스트를 추가해 반드시 로그인한 사용자만 접근이 가능하다.
import {Routes, Route} from 'react-router-dom'
import LandingPage from './LandingPage'
import Board from '../pages/Board'
import Layout from './Layout'
import RequireAuth from './Auth/RequireAuth'
import SignUp from './Auth/SignUp'
import Login from './Auth/Login'
import Logout from './Auth/Logout'
import NoMatch from './NoMatch'
export default function routesSetup() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<LandingPage />} />
<Route path="/board" element={
<RequireAuth>
<Board />
</RequireAuth>
}
/>
<Route path="*" element={<NoMatch />} />
</Route>
<Route path="/signup" element={<SignUp />} />
<Route path="/login" element={<Login />} />
<Route
path="/logout"
element={
<RequireAuth>
<Logout />
</RequireAuth>
}
/>
<Route path="*" element={<NoMatch />} />
</Routes>
)
}
'FrontEnd > React' 카테고리의 다른 글
[React] 익스프레스 프레임워크로 API 서버 만들기 (0) | 2025.01.06 |
---|---|
[React] 프로그래밍으로 몽고DB 사용하기 (1) | 2025.01.03 |
[React] Outlet 컴포넌트와 중첩 라우팅 (0) | 2024.12.27 |
[React] 처음 만나는 리액트 라우터 (1) | 2024.12.27 |
[React] 트렐로 따라 만들기 (2) (1) | 2024.12.20 |