ECC

5주차

avocado8 2024. 4. 19. 23:56

 

 

6. 리액트 라우터

6-1. 처음 만나는 리액트 라우터

URL이란?

인터넷에서 자원(웹 브라우저가 이해할 수 있는 모든 형태의 데이터)의 위치. 웹 브라우저가 특정 웹서버에 원하는 자원을 요청할 때 사용

구성: 통신프로토콜 - 도메인(호스트, 웹 서버 이름) - 경로 - 쿼리 매개변수 - 프레그먼트

브라우저는 프로토콜/도메인/ 을 도메인에 요청하고, 서버는 해당 경로헤 해당하는 자원을 HTTP 프로토콜 사용해 응답

 

location 객체

href: 주소창에 입력된 URL 전체 문자열을 얻거나 다른 URL을 프로그래밍으로 설정할 때 사용

protocl: URL의 프로토콜 문자열을 얻고 싶을 때 사용(마지막 콜론:도 포함)

host: 도메인과 포트번호가 결합된 문자열 얻을 때

pathname: / 문자 뒤 경로 부분의 문자열을 얻을 떄

search: 쿼리 매개변수 문자열을 얻을 때

hash: 프래그먼트 문자열을 얻을 때

 

history 객체

back() : 뒤로가기

forward() : 앞으로가기

go(숫자 ro URL) : 음수면 뒤로가기, 양수면 앞으로가기

 

라우팅

웹서버에서 URL에 명시된 자원을 찾는 과정

클라이언트 측 라우팅(client-side routing) : 웹 브라우저에서 발생하는 라우팅

 

SPA 방식 웹 앱의 특징

MPA(multi page application) : 웹서버와 웹브라우저가 여러 HTML 파일을 주고받는 방식

SPA(single page application) : 라우팅이 웹브라우저에서만 일어나는 웹 방식

SPA 방식 웹앱은 사용자가 처음 서버에 접속할 때 다양한 컴포넌트들로 구성된 1개의 HTML파일을 웹 브라우저에 전송하고, 이 HTML파일에 포함된 자바스크립트 코드가 동작하면서 사용자가 원하는 내용을 DOM구조로 만들어가면서 보여줌

실제 서버에 전송하는 URL이 아니므로 사용자가 보고 있는 컴포넌트가 바뀌어도 화면 새로고침이 발생하지 않음

 

리액트 라우터 패키지

컨텍스트 기반으로 설계되었으므로 컨텍스트 제공자인 BrowserRouter를 최상위 컴포넌트로 사용 필요

npm i react-router-dom

 

Route 컴포넌트 : path와 element 속성 제공. 해당 path에서 element에 설정한 컴포넌트를 화면에 보이게 설정

Route컴포넌트는 항상 Routes컴포넌트의 자식 컴포넌트로 사용해야 함

 

모든 경로에 대해 not found 페이지를 표시하는 라우팅 🔽

//src/routes/RoutesSetsup.tsx
import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="*" element={<NoMatch />} />
    </Routes>
  )
}

 

물론 App.tsx에도 BrowserRouter 설정해줘야 함

import { DndProvider } from 'react-dnd';
import './App.css';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Provider as ReduxProvider } from 'react-redux';
import { useStore } from './store';
import { BrowserRouter } from 'react-router-dom';
import RoutesSetup from './routes/RoutesSetup';

function App() {
  const store = useStore()
  return(
    <ReduxProvider store={store}>
      <DndProvider backend={HTML5Backend}>
        <BrowserRouter>
          <RoutesSetup/>
        </BrowserRouter>
      </DndProvider>
    </ReduxProvider>
  )
}

export default App;

 

 

Home 컴포넌트 만들기

//App.tsx
import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";
import Home from "./Home";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/welcome" element={<Home title="welcome" />} />
      <Route path="*" element={<NoMatch />} />
    </Routes>
  )
}

title을 props로 받는 Home 컴포넌트를 만들고 / 경로에서는 기본(home), /welcome에서는 "welcome" 이 표시되도록 라우팅

 

Link 컴포넌트

<a> 태그는 클라이언트 측 라우팅을 위한 용도로는 바로 사용할 수 없기에 리액트 라우터에서 Link 컴포넌트 제공

href속성 대신 to 속성을 사용

//routes/Home.tsx
import { FC } from "react"
import { Link } from "react-router-dom"

type HomeProps = {
  title?: string
}

const Home: FC<HomeProps> = ({title}) => {
  return (
    <div>
      <div className="flex bg-gray-200 p-4">
        <Link to="/">Home</Link>
        <Link to="/welcome" className="mt-4">Welcome</Link>
        <Link to="/board" className="ml-4">Board</Link>
      </div>
      <p className="text-bold text-center text-xl">{title ?? 'Home'}</p>
    </div>
  )
}
export default Home

 

 

useNavigate 훅

호출 시 navigate함수(매개변수로 전달한 경로로 이동)를 얻음.

history 객체의 go 메서드처럼 -1과 같은 숫자 사용 가능

//routes/NoMatch.tsx
import { useCallback } from "react";
import { useNavigate } from "react-router-dom"
import { Button } from "../theme/daisyui";

export default function NoMatch() {
  const navigate = useNavigate();

  const goBack = useCallback(() => {
    navigate(-1); //한 페이지 뒤로가기
  }, [navigate])
  return (
    <div className="flex flex-col p-4">
      <p className="p-4 text-xl text-center alert alert-error">
        Oops! No Page Found!
      </p>
      <div className="flex justify-center mt-4">
        <Button className="ml-4 btn-primary btn-xs" onClick={goBack}>GO BACK</Button>
      </div>
    </div>
  )
}

없는 주소 고의로 입력 시 나오는 화면. GO BACK 버튼을 누르면 이전 페이지로 back

 

 

 

라우트 변수

콜론을 앞에 붙인 uuid, title과 같은 심볼. 라우트 경로의 일정 부분이 수시로 바뀔 때 사용

/board/card 부분은 같지만 경로 끝의 카드 uuid값에 따라 라우트 경로가 수시로 바뀌도록 BoardList를 수정

//pages/BoardList/index.tsx
import { FC, useCallback, useMemo } from "react"
import { List } from "../../store/commonTypes"
import { Icon } from "../../theme/daisyui"
import { useCards } from "../../store/useCards"
import ListCard from "../ListCard"
import { CardDroppable, Div, ListDraggable, MoveFunc } from "../../components"
import { useNavigate } from "react-router-dom"

export type BoardListProps = {
  list: List
  onRemoveList?: () => void
  index: number
  onMoveList: MoveFunc
}

const BoardList: FC<BoardListProps> = ({list, onRemoveList, index, onMoveList, ...props}) => {
  const {cards, onPrependCard, onAppendCard, onRemoveCard} = useCards(list.uuid)

  const navigate = useNavigate();
  const cardClicked = useCallback(
    (cardid: string) => () => {
      navigate(`/board/card/${cardid}`)
    }, [navigate])
  
  const children = useMemo(
    () =>
      cards.map((card, index) => (
        <ListCard key={card.uuid} card={card} onRemove={onRemoveCard(card.uuid)}
        draggableId={card.uuid} index={index}
        onClick={cardClicked(card.uuid)}/>
      )),
      [cards, onRemoveCard]
  )

  return(
    <ListDraggable id={list.uuid} index={index} onMove={onMoveList}>
      <Div {...props} className="p-2 m-2 border border-gray-300 rounded-lg">
        <div className="flex justify-between mb-2">
          <p className="w-32 text-sm font-bold underline line-clamp-1">{list.title}</p>
        </div>
        <div className="flex justify-between ml-2">
          <Icon name="remove" className="btn-error btn-xs" onClick={onRemoveList} />
          <div className="flex">
            <Icon name="post_add" className="btn-success btn-xs" onClick={onPrependCard} />
            <Icon name="playlist_add" className="ml-2 btn-success btn-xs" onClick={onAppendCard} />
          </div>
        </div>
        <CardDroppable droppableId={list.uuid}>{children}</CardDroppable>
      </Div>
    </ListDraggable>
  )
}
export default BoardList

 

라우트 변수를 이용해 RoutesStep.tsx 수정

import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";
import Home from "./Home";
import Board from "../pages/Board";
import Card from "./Card";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/welcome" element={<Home title="welcome" />} />
      <Route path="/board" element={<Board />} />
      <Route path="/board/card/:cardid" element={<Card/>} />
      <Route path="*" element={<NoMatch />} />
    </Routes>
  )
}

 

Card 컴포넌트 제작

import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "../theme/daisyui";

export default function Card() {
  const location = useLocation();
  const navigate = useNavigate();
  const goBack = useCallback(() => {
    navigate(-1);
  }, [navigate])

  return (
    <div>
      <p>location: {JSON.stringify(location, null, 2)}</p>
      <Button className="mt-4 btn-primary btn-xs"
      onClick={goBack}>GO BACK</Button>
    </div>
  )
}

card를 클릭하면 locaton 객체의 정보를 띄운다

 

useParams, useSearchParams 훅

useParams : params 객체를 얻고, 이 params 객체로부터 라우트 매개변수값을 얻음. 라우트_매개변수_이름: 값 형태의 Record<string, any> 타입 객체

useSearchParams: searchParams 객체와 setSearchParams 세터함수를 튜플 형태로 반환

수정한 카드 컴포넌트

import { useCallback } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "../theme/daisyui";

export default function Card() {
  const location = useLocation();
  const params = useParams();
  const navigate = useNavigate();
  const [search] = useSearchParams();

  const goBack = useCallback(() => {
    navigate(-1);
  }, [navigate])

  return (
    <div>
      <p>location: {JSON.stringify(location, null, 2)}</p>
      <p>params: {JSON.stringify(params, null, 2)}</p>
      <p>cardid: {params['cardid']}</p>
      <p>from: {search.get('from')}, to: {search.get('to')}</p>
      <Button className="mt-4 btn-primary btn-xs"
      onClick={goBack}>GO BACK</Button>
    </div>
  )
}

쿼리 매개변수 부분을 따로 작성하지 않아서 from과 to는 비어있음

 

 

카드 상세 페이지 만들기

라우트 경로를 주소창에서 임의로 변경해 useParams로 얻은 cardid값이 없거나 null일 때 오류가 나면 안 되므로 조건문을 사용해 방어적인 코드 작성

import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "../theme/daisyui";
import { useSelector } from "react-redux";
import { AppState } from "../store";
import * as CE from '../store/cardEntities';
import type { Card as CardType } from "../store/commonTypes";
import { Avatar, Div } from "../components";

export default function Card() {
  const location = useLocation();
  const params = useParams();
  const navigate = useNavigate();
  const [search] = useSearchParams();

  const goBack = useCallback(() => {
    navigate(-1);
  }, [navigate])

  const [card, setCard] = useState<CardType|null>(null)
  const {cardid} = params
  const cardEntities = useSelector<AppState, CE.State>(({cardEntities}) => cardEntities);

  useEffect(() => {
    if(!cardEntities || !cardid) return
    cardEntities[cardid] && setCard(notUsed => cardEntities[cardid])
  }, [cardEntities, cardid])

  if(!card){
    return (
      <div className="p-4">
        <p>location: {JSON.stringify(location, null, 2)}</p>
        <p>params: {JSON.stringify(params, null, 2)}</p>
        <p>cardid: {params['cardid']}</p>
        <p>from: {search.get('from')}, to: {search.get('to')}</p>
        <Button className="mt-4 btn-primary btn-xs"
        onClick={goBack}>GO BACK</Button>
      </div>
    )
  }

  return(
    <div className="p-4">
      <Div src={card.image} className="w-full" minHeight="10rem" height="10rem" />
      <Div className="flex flex-row items-center mt-4">
        <Avatar src={card.writer.avatar} size="2rem" />
        <Div className="ml-2">
          <p className="text-xs font-bold">{card.writer.name}</p>
          <p className="text-xs text-gray-500">{card.writer.jobTitle}</p>
        </Div>
      </Div>
      <Button className="mt-4 btn-primary btn-xs"
        onClick={goBack}>GO BACK</Button>
    </div>
  )
  
}

정상적으로 생성한 카드를 누르면 상세 페이지로 이동

 

 

 

6-2. Outlet 컴포넌트와 중첩 라우팅

Outlet 컴포넌트

내비게이션 메뉴 등에서 모든 라우트 컴포넌트마다 똑같은 코드를 작성하는 번거로움을 줄이고자 제공하는 컴포넌트

다른 컴포넌트들이 렌더링되는 위치를 지정해주는 역할

Layout/index.tsx에 Outlet을 리턴시키고 RoutesSetup.tsx를 아래와 같이 작성

import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";
import Layout from "./Layout";
import Board from "../pages/Board";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route path="/board" element={<Board />} />
        <Route path="*" element={<NoMatch />} />
      </Route>
    </Routes>
  )
}

Outlet컴포넌트는 자신의 자식 라우트 경로에 설정된 컴포넌트를 화면에 나타나게 하는 역할을 함.

중첩 라우팅 : 부모/자식 관계의 라우팅. 리액트에서는 부모 컴포넌트는 자식 컴포넌트 안에서 렌더링될 수 없고, 형제 컴포넌트가 다른 형제 컴포넌트 안에서 렌더링될 수도 없으므로, Nomatch가 Outlet 안에서 렌더링되려면 부모/자식관계의 중첩라우팅 형태로 설정해야 함 

 

색인 라우트

Route 컴포넌트는 index라는 이름의 boolean 타입 속성을 제공함

<Route index/> 형태로 사용하는 라우트를 색인 라우트라고 함

import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";
import Layout from "./Layout";
import Board from "../pages/Board";
import LandingPage from "./LandingPage";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<LandingPage />} />
        <Route path="/board" element={<Board />} />
        <Route path="*" element={<NoMatch />} />
      </Route>
    </Routes>
  )
}

색인 라우트는 경로가 '/' 일 때 Outlet을 채울 컴포넌트를 지정함

 

내비게이션 메뉴와 바닥글 만들기

내비게이션바와 푸터 페이지를 만들고 Layout/index.tsx에 넣어주면 이제 어느 페이지에 가도 보인다

export default function Layout() {
  return (
    <>
      <NavigationBar />
      <Outlet />
      <Footer />
    </>
  )
}

 

랜딩 페이지 만들기

랜딩 페이지: 사용자가 특정 웹사이트에 접속할 때 처으 보게 되는 페이지.

히어로 이미지: 랜딩페이지에서 해당 웹사이트를 대표하는 이미지

히어로 영역: 히어로 이미지와 함께 웹사이트를 대표하는 슬로건과 대표메뉴로 가는 버튼을 함께 제공하는 영역

프로모션 영역: 마케팅적 측면에서, 특정 고객의 평가를 구체적인 사례로 보여주는 부분

라우팅과 관련된 부분은 따로 없고 그냥 데이터 넣어주기라 코드는 생략...

 

커스텀 Link 컴포넌트 만들기

커스텀 링크 컴포넌트를 만들어 내비게이션 바에서 현재 페이지에 해당하는 메뉴에만 밑줄을 표시하는 액션을 추가해보자

import { FC } from "react";
import type { LinkProps as RRLinkProps } from "react-router-dom";
import { Link as RRLink, useMatch, useResolvedPath } from "react-router-dom";

export type LinkProps = RRLinkProps & {}

export const Link: FC<LinkProps> = ({className: _className, to, ...props}) => {
  const resolved = useResolvedPath(to)
  const match = useMatch({path: resolved.pathname, end: true})
  const className = [_className, match ? 'btn-active' : ''].join(' ');
  return <RRLink {...props} to={to} className={className} />
}

useResolvedPath는 현재페이지의 pathname, search, hash값을 반환

useMatch는 params, pathname, pathnameBase, pattern을 반환

활성화되지 않은 메뉴는 match값이 null이다.

그러니 match가 null이 아닐 때만 btn-active 클래스를 추가하여 스타일을 적용해줄 수 있다.

 

 

6-3. 공개 라우트와 비공개 라우트 구현하기

공개 라우트: 누구나 접속할 수 있는 경로

비공개 라우트: 로그인한 사용자만 접속할 수 있는 경로

 

사용자 인증 컨텍스트 만들기

사용자가 로그인이나 회원가입을 했는지 알려면 모든 비공개경로의 컴포넌트는 사용자의 정보를 알 수 있어야 함.

-> 컨텍스트나 리덕스 사용 필요

//src/contexts/AuthContext.tsx
import { FC, PropsWithChildren, createContext, useCallback, useContext, useState } 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)
}

export한 후 App.tsx의 RoutesSetup을 AuthProvider로 감싸준다

RoutesSetup에서 Signup, login, logout, nomatch는 내비게이션이나 푸터와 함께 나타나면 안 되므로 레이아웃 라우터 밖으로 빼서 라우팅해준다

 

내비게이션 바 수정

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">
        <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>
        )}
        {!loggedUser && (
          <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>
  )
}

비로그인 상태에서는 Board 메뉴와 Logout 버튼이 보이지 않게 수정

 

 

회원 가입 기능 만들기

반복적인 코드를 줄이기 위해 <input> 요소의 value 속성에 설정할 변수 이름을 속성으로 가진 Record 타입을 만들어준다

type SignUpFormType = Record<'email' | 'password' | 'confirmPassword', string>
//Auth/SignUp.tsx
import { ChangeEvent, useCallback, useState } from 'react'
import * as D from '../../data'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts'

type SignUpFormType = Record<'email' | 'password' | 'confirmPassword', string>
const initialFormState = {email: D.randomEmail(), password: '1', confirmPassword: '1'}

export default function SignUp() {
  const [{email, password, confirmPassword}, setForm] = useState<SignUpFormType>(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('/'))
    } else {
      alert('password is not equal')
    }
  }, [email, password, confirmPassword, navigate, signup])

  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" name="email" placeholder='Email'
          value={email} onChange={changed('email')}
          className="w-full p-3 mb-4 input input-primary" />
          <input type="password" name="password" placeholder='Password'
          value={password} onChange={changed('password')}
          className="w-full p-3 mb-4 input input-primary" />
          <input type="password" name="confirm_password" placeholder='Confirm Password'
          value={confirmPassword} onChange={changed('confirmPassword')}
          className="w-full p-3 mb-4 input input-primary" />
          <button type="submit" className='w-full btn btn-primary'
          onClick={createAccount}>CREATE ACCOUNT</button>
        </div>

        <div className='mt-6 text-grey-800'>
          Alredy have an account?
          <Link className='btn btn-link btn-primary' to='/login'>LOG IN</Link>
        </div>
      </div>
    </div>
  )
}

로그인 시 홈으로 이동

 

localStorage

회원가입 시의 이메일 주소를 로그인 시 이메일 주소에 표시하도록 해보자

로컬스토리지는 웹브라우저가 접속한 웹사이트별로 데이터를 저장할 수 있는 공간으로, 웹브라우저가 종료되어도 그대로 남아 있음

//src/utils/localStorageP.ts
export const readItemFromStorageP = (key: string) =>
  new Promise<string | null>(async (resolve, reject) => {
    try {
      const value = localStorage.getItem(key)
      resolve(value)
    } catch(e) {
      reject(e)
    }
  })
export const writeItemFromStorageP = (key: string, value: string) =>
  new Promise<string>(async (resolve, reject) => {
    try {
      localStorage.setItem(key, value)
      resolve(value)
    } catch(e) {
      reject(e)
    }
  })

export const readStringP = readItemFromStorageP
export const writeStringP = writeItemFromStorageP

로그인 기능은 confirmPassword만 제외하고 동일하게 만들어준다

 

로그아웃 기능 만들기

//Logout.tsx
import { useNavigate } from "react-router-dom"
import { useToggle } from "../../hooks"
import { useAuth } from "../../contexts"
import { useCallback } from "react"
import { Button, Modal, ModalAction, ModalContent } from "../../theme/daisyui"

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 onCancle = useCallback(() => {
    toggleOpen()
    navigate(-1)
  }, [navigate, toggleOpen])

  return (
    <Modal open={open}>
      <ModalContent closeIconClassName="btn-primary btn-outline"
      onCloseIconClicked={onCancle}>
        <p className="text-xl text-center">Are you sure you want to log out?</p>
        <ModalAction>
          <Button className="btn-primary btn-sm" onClick={onAccept}>LOGOUT</Button>
          <Button className="btn-primary btn-sm" onClick={onCancle}>CANCEL</Button>
        </ModalAction>
      </ModalContent>
    </Modal>
  )
}

 

 

로그인한 사용자만 접근하도록 막기 (비공개 라우팅)

useAuth에서 얻은 loggdUser값이 undefined인지 검사하는 로직 추가

단 모든 컴포넌트에서 구현하면 번거로우므로 RequireAuth 컴포넌트를 만들어 모든 비공개 라우트에 필요한 기능을 한군데에 구현하고, 비공개 라우트를 RequireAuth로 감싸는 방식으로 만든다.

//Auth/RequireAuth.tsx
import { FC, PropsWithChildren, useEffect } from "react"
import { useAuth } from "../contexts"
import { useNavigate } from "react-router-dom"

type RequireAuthProps = {}

const RequireAuth: FC<PropsWithChildren<RequireAuthProps>> = ({children}) => {
  const {loggedUser} = useAuth()
  const navigate = useNavigate()
  useEffect(() => {
    if(!loggedUser) navigate(-1)
  }, [loggedUser, navigate])

  return <>{children}</> //loggedUser값이 undefined가 아니면 children 속성에 담긴 요소를 화면에 나타나게 함
}

export default RequireAuth
//RoutesSetup.tsx
import { Route, Routes } from "react-router-dom";
import NoMatch from "./NoMatch";
import Layout from "./Layout";
import Board from "../pages/Board";
import LandingPage from "./LandingPage";
import SignUp from "./Auth/SignUp";
import Login from "./Auth/Login";
import Logout from "./Auth/Logout";
import RequireAuth from "./Auth/RequireAuth";

export default function RoutesSetup() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<LandingPage />} />
        <Route path="/board" element={
          <RequireAuth>
            <Board />
          </RequireAuth>
        } />
      </Route>
      <Route path="/signup" element={<SignUp />} />
      <Route path="/login" element={<Login />} />
      <Route path="/logout" element={
        <RequireAuth>
          <Logout />
        </RequireAuth>
      } />
      <Route path="*" element={<NoMatch />} />
    </Routes>
  )
}

비공개라우트(Board, Logout)는 RequireAuth로 감싸준다

이제 주소창에 직접 경로를 입력해도 로그인한 상태가 아니면 이전 페이지로 강제 이동된다.

 

 

 

7. 몽고DB와 API 서버

7-1. 몽고DB 이해하기

현재 데이터베이스 시장은 크게 관계형 데이터베이스 시스템(RDBMS, ex 오라클)과 NoSQL 데이터베이스 시스템(NonSQL, ex 몽고DB)으로 양분되어 있음

RDBMS는 SQL을 사용하나 NoSQL시스템은 SQL을 사용하지 않고 자바스크립트와 같은 다른 방식의 질의어를 사용

몽고DB는 JSON포맷으로 바꿀 수 있는 모든 자바스크립트 객체를 자유롭게 저장할 수 있어서 배열 데이터를 저장하고 다루기가 매우 편리함. 또한 자바스크립트 언어를 질의어로 사용하므로 특정 데이터를 조회할 때 구현 방법이 자유로움

 

몽고쉘 실행

mongosh

 

show dbs //현재 설치된 DB 목록 보기
use db이름 //해당 db 사용하기 위해 선택
db //사용 중인 DB 이름 보기
use mydb // mydb라는 이름의 db 생성

* 새로운 DB는 실제 데이터가 새로운 DB에 생성될 때 함께 만들어짐

local> use mydb
switched to db mydb
mydb> db
mydb
mydb> db.user.insertOne({name : 'cado'})

{
  acknowledged: true,
  insertedId: ObjectId('662209db26c47a61cf117b7b')
}

데이터를 insert하면 위와 같은 출력을 볼 수 있다

DB 삭제도 use 명령으로 삭제하고 싶은 DB 선택 -> db.dropDatabase() 사용

 

컬렉션과 문서

몽고DB에서는 RDBMS의 테이블을 컬렉션이라고 부르는데, 컬렉션은 스키마가 없는 저장소이다.

RDBMS의 레코드에 해당하는 한 건의 데이터를문서라고 한다.

즉 컬렉션은 스키마 없이 자유롭게 작성된 여러개의 문서를 보관하는 저장소이다.

 

컬렉션 만들기

db.createCollection("컬렉션명", {}) //{}: 컬렉션 설정 옵션 지정

 

컬렉션 목록 보기

db.getCollectionNames()

 

컬렉션 삭제하기

db.컬렉션이름.drop()

 

_id 필드와 ObjectId 타입

_id 필드: 문서가 DB에 저장될 때 자동으로 만들어짐. uuid와 개념적으로 같지만 - 기호 없이 ObjectId("문자열") 형태로 사용

ObjectId('유닉스 시간 - 랜덤값 - 카운트')

 

컬렉션의 CRUD 메서드

생성(C) 검색(R) 수정(U) 삭제(D) 메서드

1) 문서 생성 메서드

mydb> db.user.insertOne({name: 'Cado', age: 6})
{
  acknowledged: true,
  insertedId: ObjectId('662213d226c47a61cf117b7c')
}
mydb> db.user.insertMany([{name: 'Nana', age: 5}, {name: 'UBeol', age: 4}])
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('662213f326c47a61cf117b7d'),
    '1': ObjectId('662213f326c47a61cf117b7e')
  }
}

 

2) 문서 검색 메서드

mydb> db.user.findOne({}) //제일 먼저 찾은 문서 하나만 반환
{ _id: ObjectId('662213d226c47a61cf117b7c'), name: 'Cado', age: 6 }
mydb> db.user.find({}) //조건에 맞는 문서 모두 반환
[
  { _id: ObjectId('662213d226c47a61cf117b7c'), name: 'Cado', age: 6 },
  { _id: ObjectId('662213f326c47a61cf117b7d'), name: 'Nana', age: 5 },
  { _id: ObjectId('662213f326c47a61cf117b7e'), name: 'UBeol', age: 4 }
]
mydb> db.user.find({name: 'Cado'})//특정 name 필드값을 가진 문서 찾기
[ { _id: ObjectId('662213d226c47a61cf117b7c'), name: 'Cado', age: 6 } ]
mydb> db.user.find({age: {$gt: 5}}) //몽고DB 연산자를 사용해 age가 5보다 큰 문서 찾기
[ { _id: ObjectId('662213d226c47a61cf117b7c'), name: 'Cado', age: 6 } ]

* 연산자: 달러사인$을 접두사로 사용하는 키워드.

비교연산자: $eq, ne, gt, gte, lt, lte

$in : 어떤 필드가 여러 값 중에서 일치하는 값을 가지는 문서를 찾을 때. 연산자에 설정한 배열에서 하나라도 매치되면 해당 문서 반환

$nin: 여러 값 중에서 하나도 일치하지 않는 값을 가질 때. 연산자에 설정한 배열에서 모두 매치되지 않는 문서 반환

논리연산자: $and not or nor

mydb> db.user.find({$and: [{name: {$in: ['Cado', 'Nana']}}, {age: {$lt: 6}}]})
[ { _id: ObjectId('662213f326c47a61cf117b7d'), name: 'Nana', age: 5 } ]

name이 'Cado' 또는 'Nana'이고, age는 6보다 작은 데이터를 찾는 명령어

 

$regex 정규식 연산자

보통 / 문자를 정규식 앞뒤에 붙여 간결하게 표현

mydb> db.user.find({name: {$regex: /^N.*$/}})
[ { _id: ObjectId('662213f326c47a61cf117b7d'), name: 'Nana', age: 5 } ]

name이 문자 'N'으로 시작하는 모든 문서를 찾는 명령어

 

3) 필드 업데이트 연산자

{$set: {필드이름: 값, ...}} 문서의 특정 필드값 변경

{$inc: {필드이름: 값, ...}} 문서의 숫자타입 필드값 증가

{$dec: {필드이름: 값, ...}} 문서의 숫자타입 필드값 감소

 

4) 문서 수정 메서드

updateOne(검색조건객체, 필드업데이트연산자객체, 옵션) : 수정 후 값을 반환함

updateMany(검색조건객체, 필드업데이트연산자객체, 옵션)

findOneAndUpdate(검색조건객체, 필드업데이트연산자객체, 옵션) : 수정 전 값을 반환함. 수정된 값을 반환받으려면 옵션에서 returnNewDocument 속성값을 true로 설정

mydb> db.user.updateOne({name: {$regex: /^N.*$/}}, {$set: {name: 'Nado'}, $inc: {age: 10}})

name이 N으로 시작하는 문서를 찾아 name을 Nado로, age는 10만큼 증가

결과 🔽

mydb> db.user.find({})
[
  { _id: ObjectId('662213d226c47a61cf117b7c'), name: 'Cado', age: 6 },
  { _id: ObjectId('662213f326c47a61cf117b7d'), name: 'Nado', age: 15 },
  { _id: ObjectId('662213f326c47a61cf117b7e'), name: 'UBeol', age: 4 }
]

 

5) 문서 삭제 메서드

deleteOne(검색조건객체, 옵션) : 맨 처음 찾은 문서를 삭제

deleteMany(검색조건객체, 옵션) : 찾은 문서 전부 삭제

 

 

7-2. 프로그래밍으로 몽고DB 사용하기

몽고DB 연결하기

몽고DB 연결 URL: mongodb://localhost:27017

mongodb 패키지는 MongoClient 클래스를 제공. connect 정적 메서드를 제공하여 프로미스 형태로 MongoClient 인스턴스를 얻음

//src/mongodb/connectAndUseDB.ts
import { Db, MongoClient } from "mongodb";

export type MongoDB = Db
export type ConnectCallback = (db: MongoDB) => void

export const connectAndUseDB = async (
  callback: ConnectCallback,
  dbName: string,
  mongoUrl: string = 'mongodb://localhost:27017'
) => {
  let connection
  try {
    connection = await MongoClient.connect(mongoUrl) //몽고DB와 연결
    const db: Db = connection.db(dbName) //몽고셸의 use dbName에 해당
    callback(db) //db객체를 콜백함수의 매개변수로 호출
  } catch(e){
    if(e instanceof Error){
      console.log(e.message)
    }
  }
}

 

연결 테스트를 위해 test/connectTest.ts 작성

import * as M from '../mongodb'

const connectCB = (db: M.MongoDB) => {
  console.log('db', db)
}
const connectTest = () => {
  M.connectAndUseDB(connectCB, 'ch07')
}

connectTest()

몽고DB 응답

 

컬렉션의 CRUD 메서드 (프로그래밍)

문서생성

import * as M from '../mongodb'

const connectCB = async(db: M.MongoDB) => {
  try {
    const user = db.collection('user')
    try {
      await user.drop()
    } catch(e) {  
    }

    const jack = await user.insertOne({name: 'Jack', age: 32})
    console.log('jack', jack)
    const janeAndTom = await user.insertMany([
      {name: 'Jane', age: 22},
      {name: 'Tom', age: 11}
    ])
    console.log('janeAndTom', janeAndTom)
  } catch(e) {
    if (e instanceof Error) {
      console.log(e.message)
    }
  }
}

const insertTest = () => {
  M.connectAndUseDB(connectCB, 'ch07')
}

insertTest()

 

나머지 메서드도 쉘에서 했던 것과 유사한 방식으로 구현할 수 있다...

 

7-3. 익스프레스 프레임워크로 API 서버 만들기

TCP/IP 프로토콜

리슨: 서버 프로그램은 항상 클라이언트의 데이터 요청이 있는지 알기 위해 특정 포트를 감시하고 있는 과정

어떤 특정 TCP/IP 서버가 특정 포트를 리슨하고 있을 때 다른 TCP/IP 서버는 이 포트에 접근하지 못함

TCP/IP 연결이 되면 클라이언트와 서버 모두 소켓이라는 토큰을 얻는다. 이 소켓을 통해 클라이언트와 서버가 양방향으로 데이터를 주고받는다. 소켓은 소켓 번호로 한 대의 서버가 각각의 클라이언트를 구분할 수 있게 해준다.

HTTP 프로토콜은 TCP/IP 프로토콜 위에서 동작하는 앱 수준 프로토콜.

 

Node.js 웹 서버 만들기

Node.js : 웹브라우저의 자바스크립트 엔진 부분만 떼어내어 C/C++ 언어로 HTTP프로토콜을 구현하여 독립적인 프로그램 형태로 동작하는 웹서버 기능을 가진 자바스크립트 엔진을 구현한 것

http 패키지를 기본으로 제공. createServer 함수로 웹서버 객체를 만들 수 있음

//src/index.ts
import { createServer } from "http"

const hostname = "localhost", port = 4000
createServer((req, res) => {
  console.log('req.url', req.url)
  console.log('req.method', req.method)
  console.log('req.headers', req.headers)
  res.write('hello world!')
  res.end()
}).listen(port, () => console.log(`connect http://${hostname}:${port}`))

4000번 포트를 리슨하는 서버

실행시 localhost:4000에서 메시지가 출력됨
콘솔

 

REST 방식 API 서버

웹서버: HTML 형식 데이터 전송

API서버: JSON 형식 데이터 전송

REST : req.method의 설정값을 다르게 하여 API 서버쪽에서 DB의 CRUD 작업을 쉽게 구분할 수 있게 하는 용도로 사용

기본 원리는 경로에 자신이 원하는 자원을 명시하고, 자원에 새로운 데이터를 생성하려면 POST 메소드, 검색하려면 GET 메소드를 사용하는 것

특정 자원을 일정하게 사용하므로 일관된 방식으로 API를 설계할 수 있다.

 

익스프레스 프레임워크

Node.js 환경에서 사실상 표준 웹 프레임워크.

express, cors 패키지를 설치한다. (+nodemon)

익스프레스로 웹서버를 만들 때는 항상 app 객체를 먼저 만들어야 한다. app객체는 4개의 HTTP메서드에 대응하는 4개의 메서드 (post, get, put, delete)를 제공하며, 이 메서드들은 항상 app객체를 다시 반환한다.

app.메서드명(경로, (req, res) => {}) 패턴으로 사용

//index.ts
import { createServer } from "http"
import express from "express"

const hostname = "localhost", port = 4000

const app = express()
  .get('/', (req, res) => {
    res.json({message: 'hello express!'}) //json 형식 데이터를 전송
  })

createServer(app).listen(port, () => console.log(`connect http://${hostname}:${port}`))

localhost:4000 경로에서 웹브라우저의 모습

 

익스프레스 미들웨어와 use 메서드

app.use(미들웨어)

여러가지 다양한 기능을 미들웨어를 통해 쉽게 사용할 수 있게 함

미들웨어는 req, res, next 3개의 매개변수로 구성된 함수 형태로 구현. next는 함수인데 이를 호출하면 모든것이 정상으로 동작함. 그러나 next를 호출하지 않으면 이 미들웨어 아래쪽 체인으로 연결된 메서드들이 호출되지 않음

//express/index.ts
import express from 'express';

export const createExpressApp = (...args: any[]) => {
  const app = express()
  app
  .use((req, res, next) => {
    console.log(`url=${req.url}, method=${req.method}`)
    next()
  })
  .get('/', (req, res) => {
    res.json({message: 'hello express!'})
  })
  return app
}

 

 

express.static 미들웨어

익스프레스 객체가 public 디렉터리에 있는 파일을 웹 브라우저에 응답할 수 있게 하는 정적 파일 서버로 동작할 수 있게 하는 미들웨어

app.use(express.static('public'))

 

getPublicDirPath 함수 구현하기

express.static 미들웨어는 public과 같은 디렉터리를 실제로 생성하지는 않음 .. 그럼 실제로 생성하려면?

일단 해당 디렉터리의 절대경로를 알아내기 위한 코드 작성🔽

//src/config/index.ts
import path from "path";

export const getPublicDirPath = () => path.join(process.cwd(), 'public')

process.cwd() : 프로젝트의 package.json 파일이 있는 디렉터리의 절대경로 반환

path.join() : 매개변수에 나열된 모든 경로를 해당 운영체제의 디렉터리 구분 문자(윈도우는 \)를 사용해 문자열 1개로 만들어줌

 

Node.js는 파일시스템을 의미하는 fs 패키지를 제공.

fs.함수이름Sync : 동기함수. 작업이 끝날 때까지 결괏값 반환x

fs.함수이름 : 비동기함수. 함수를 호출하면 결괏값을 콜백함수로 반환

//src/utils/makeDir.ts
import fs from 'fs';

export const makeDir = (dirName: string) => {
  if(false == fs.existsSync(dirName)) fs.mkdirSync(dirName) //dirName 디렉터리가 현재 없으면 디렉터리 생성
}
//src/index.ts
import { createServer } from "http"
import { createExpressApp } from "./express"
import { makeDir } from "./utils"
import { getPublicDirPath } from "./config"

makeDir(getPublicDirPath())

const hostname = "localhost", port = 4000
createServer(createExpressApp()).listen(port, () => console.log(`connect http://${hostname}:${port}`))

public 디렉터리에 1.png 파일을 저장하고 경로에 적어 이미지파일을 요청하면 웹브라우저가 응답

 

express.json 미들웨어

post메서드를 통해 전송한 데이터가 있을 때, 익스프레스는 이렇게 전달받은 데이터를 req.body 형태로 얻을 수 있도록 express.json 미들웨어를 제공함

 

cors 미들웨어

post로 데이터를 보낼 때 프리플라이트(preflight)요청과 응답 통신 기능을 추가해 악의적인 목적의 데이터를 서버쪽에 보내지 못하게 하는 기술.

 

익스프레스 라우터

라우터: 익스프레스에서 Router 함수를 호출해 얻은 객체. listen 메서드는 없지만 app객체와 똑같이 동작한다

단 app.use에서 사용하는 경로는 절대경로이지만 라우터 객체 내부에서 사용하는 경로는 상대경로이다.

//routers/testRouter.ts
import { Router } from "express";

export const testRouter = (...args: any[]) => {
  const router = Router();
  return router
    .get('/', (req, res) => { //모든 데이터 요청하는 경우
      res.json({ok: true})
    })
    .get('/:id', (req, res) => { //id값을 가진 데이터만 요청하는 경우
      const {id} = req.params
      res.json({ok: true, id})
    })
    .post('/', (req, res) => { //req.body의 데이터를 서버에 저장하기를 요청하는 경우
      const {body} = req
      res.json({ok: true, body})
    })
    .put('/:id', (req, res) => { //id값을 가진 데이터의 수정을 요청하는 경우
      const {id} = req.params
      const {body} = req
      res.json({ok: true, body, id})
    })
    .delete('/:id', (req, res) => { //id값을 가진 데이터의 삭제를 요청하는 경우
      const {id} = req.params
      res.json({ok: true, id})
    })
}

*HTTP 메서드별 경로변수를 활용한 보통의 구현 방법

GET: 경로 변수가 없으면 모든 데이터, 있으면 해당 id를 가진 데이터만 응답하도록 구현하는 것이 보통. 경로변수는 항상 문자열 타입이므로 다른 타입변수로 바꾸려면 parseInt(id)... 등의 타입변환 필요

POST: req.body에 담긴 데이터를 DB 등에 저장해달라고 요청하는 것이므로 id 사용 x

PUT: 특정 id값을 가진 데이터의 수정을 요청하므로 매개변수에는 해당 데이터 id값, body에는 수정 내용을 담음

DELETE: 경로 매개변수 id값을 가진 데이터를 삭제해달라는 형태

 

몽고DB 연결하기

//src/index.ts
import { createServer } from "http"
import { createExpressApp } from "./express"
import { makeDir } from "./utils"
import { getPublicDirPath } from "./config"
import { MongoDB, connectAndUseDB } from "./mongodb"

makeDir(getPublicDirPath())

const connectCallback = (db: MongoDB) => {
  const hostname = 'localhost', port = 4000

  createServer(createExpressApp(db)).listen(port, () =>
    console.log(`connect http://${hostname}:${port}`)
  )
}
connectAndUseDB(connectCallback, 'ch07')

const hostname = "localhost", port = 4000
createServer(createExpressApp()).listen(port, () => console.log(`connect http://${hostname}:${port}`))

createExpressApp 함수에 db 객체를 매개변수로 전달

createExpressApp 함수가 전달받은 db객체를 setupRouters 함수를 반환하면서 인자로 넘겨줌

setupRouters 함수는 db 객체를 전달받고, 이에 따라 testRouter은 args 배열의 첫번째 아이템에서 db객체를 얻게 됨

//routers/testRouter.ts
import { Router } from "express";
import { MongoDB } from "../mongodb";

export const testRouter = (...args: any[]) => {
  const db: MongoDB = args[0]
  const test = db.collection('test')
  const router = Router();

  return router
    .get('/', async (req, res) => { //모든 데이터 요청하는 경우
      try {
        const findResult = await test.find({}).toArray()
        res.json({ok: true, body: findResult})
      } catch(e) {
        if (e instanceof Error) res.json({ok: false, errorMessage: e.message})
      }
    })
    .get('/:id', async (req, res) => { //id값을 가진 데이터 요청
      const {id} = req.params
      try {
        const findResult = await test.findOne({id})
        res.json({ok: true, body: findResult})
      } catch(e) {
        if (e instanceof Error) res.json({ok: false, errorMessage: e.message})
      }
    })
    .post('/', async (req, res) => { //req.body의 데이터를 서버에 저장하기를 요청하는 경우
      const {body} = req
      try {
        try {
          await test.drop()
        } catch(e) {}
        const insertResult = await test.insertOne({id: '1234', ...body})
        const {insertedId} = insertResult
        const findResult = await test.findOne({_id: insertedId})
        res.json({ok: true, body: findResult})
      } catch(e) {
        if(e instanceof Error) res.json({ok: false, errorMessage: e.message})
      }
    })
    .put('/:id', async (req, res) => { //id값을 가진 데이터의 수정을 요청하는 경우
      const {id} = req.params
      const {body} = req
      try {
        const updateResult = await test.findOneAndUpdate(
          {id}, {$set: body}, {returnDocument: 'after'}
        )
        res.json({ok: true, body: updateResult && updateResult})
      } catch(e) {
        if (e instanceof Error) res.json({ok: false, errorMessage: e.message})
      }
    })
    .delete('/:id', async (req, res) => { //id값을 가진 데이터의 삭제를 요청하는 경우
      const {id} = req.params
      try {
        await test.deleteOne({id})
        res.json({ok: true})
      } catch(e) {
        if (e instanceof Error) res.json({ok: false, errorMessage: e.message})
      }
    })
}