[프로그래머스] 프론트엔드 심화: 프로젝트(4)

Lina Hongbi Ko·2024년 11월 13일
0

Programmers_BootCamp

목록 보기
55/76
post-thumbnail

2024년 11월 13일

✏️ 라우트 작성

  • React Router를 사용

  • 설치

    • npm install react-router-dom @types/react-router-dom —save
  • 라우터 구성

    • 로그인 : /login
    • 회원가입 : /signup
    • 비밀번호 초기화 : /reset
    • 도서 목록 : /books
    • 도서 상세 : /books/{id}
    • 장바구니 : /cart
    • 주문서 작성 : /order
    • 주문 목록 : /orderlist
  • 라우터 사용

// App.tsx

import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher'; 
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout><Home /></Layout>,
    errorElement: <Error />
  },
  {
    path: "/books",
    element: <Layout><div>도서 목록</div></Layout>
  }
]);

function App() {
  
  return(
    <BookStoreThemeProvider>
        <ThemeSwitcher />
        <RouterProvider router={router} />
    </BookStoreThemeProvider>
  );
}

export default App;
// components / common / Error.tsx

import { useRouteError } from 'react-router-dom';

interface RouteError {
  statusText ?: string;
  message?: string;
}

const Error = () => {
  const error = useRouteError() as RouteError;
  return(
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>다음과 같은 오류가 발생했습니다.</p>
      <p>{error.statusText || error.message}</p>
    </div>
  )
}

export default Error;
// components / common / Header.tsx

... 생략 ...

const Header = () => {
  return (
    <HeaderStyle>
      <h1 className="logo">
        <Link to="/">
          <img src={logo} alt="book store"/ >
        </Link>
      </h1>
      <nav className="category">
        <ul>
          {
            CATEGORY.map((item) => (
              <li key={item.id}>
                <Link to={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
                  {item.name}
                </Link>
              </li>
            ))
          }
        </ul>
      </nav>
      
      ... 생략 ...

✏️ 모델 정의

  • 프로젝트 모델 정의

    • 주요 모델

      1. User
      2. Book
      3. Category
      4. Cart
      5. Order
  • 모델 정의

// models / user.model.ts

export interface User {
  id: number;
  email: string;
}
// models / category.model.ts

export interface Category {
  category_id: number | null;
  category_name: string;
}
// models / book.model.ts

export interface Book {
  id: number;
  title: string;
  img: number;
  category_id: number;
  form: string;
  isbn: string;
  summary: string;
  detail: string;
  author: string;
  pages: number;
  contents: string;
  price: number;
  likes: number;
  pubDate: string;
}

export interface BookDetail extends Book {
  categoryName: string;
  liked: boolean;
}
// models / cart.model.ts

export interface Cart{
  id: number;
  bookId: number;
  title: string;
  summary: string;
  quantity: number;
  price: number;
}
// models / order.model.ts

export interface Order {
  id: number;
  createdAt: string;
  address: string;
  receiver: string;
  contact: string;
  totalQuantity: number;
  totalPrice: number;
}
// models / pagination.model.ts

export interface Pagination {
  currentPage: number;
  totalCount: number;
}

✏️ API 통신과 데이터 레이어

  • API 통신 모듈

    • 데이터 흐름
    • 레이어를 고려서 설계하면 좋은점
      • 렌더 영역을 깔끔히 유지할 수 있음 —> Header 컴포넌트에서 어떤 데이터가 필요할 때, 데이터를 직접 fetch 해서 넣으면 렌더 영역을 깔끔히 유지하긴 어려움
      • 따라서, hook이나 별도로 분리된 http 클라이언트를 사용할 수 있음
        • hook을 통해서 중복 코드를 줄이고, hook 안에서 데이터를 가공하는 등의 로직 제공 가능
      • fetcher 역시 분리해서 api마다 달라질 수 있는 설정이나 변경 사항 등을 대응 할 수 있음
  • category fetch

    • axios 사용 예정(설치 : npm install axios —save)

      1. http 공통 모듈 클라이언트 작성

        // api / http.ts
        
        import axios, { AxiosRequestConfig } from 'axios';
        
        const BASE_URL = "http://localhost:9999";
        const DEFAULT_TIMEOUT = 30000;
        
        export const createClient = (config?: AxiosRequestConfig) => {
          const axiosInstance = axios.create({
            baseURL : BASE_URL,
            timeout : DEFAULT_TIMEOUT,
            headers : {
              "content-type" : "application/json"
            },
            withCredentials: true,
            ...config,
          });
        
          axiosInstance.interceptors.response.use(
            (response) => { return response},
            (error) => {return Promise.reject(error)}
          );
        
          return axiosInstance;
        };
        
        export const httpClient = createClient();
      2. category api

        // api / category.api.ts
        
        import { Category } from '../models/category.model';
        import { httpClient  } from './http';
        
        export const fetchCategory = async () => {
          const response = await httpClient.get<Category[]>('/category');
          return response.data;
        }
      3. Header에 적용하기

        // components / common / Header
        
        import { styled } from 'styled-components';
        import logo from "../../assets/logo.png";
        import { FaSignInAlt, FaRegUser } from "react-icons/fa";
        import { Link } from 'react-router-dom';
        import { useState, useEffect } from 'react';
        import { Category } from '../../models/category.model';
        import { fetchCategory } from '../../api/category.api';
        
        const Header = () => {
          const [category, setCategory] = useState<Category[]>([]);
        
          useEffect(() =>{
            fetchCategory().then((category) => {
              setCategory(category);
            });
          }, []);
        
          return (
            <HeaderStyle>
              <h1 className="logo">
                <Link to="/">
                  <img src={logo} alt="book store"/ >
                </Link>
              </h1>
              <nav className="category">
                <ul>
                  {
                    category.map((item) => (
                      <li key={item.category_id}>
                        <Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
                          {item.category_name}
                        </Link>
                      </li>
                    ))
                  }
                </ul>
              </nav>
              ... 생략 ...

  • header 컴포넌트는 렌더 역할을 하는데 장황한 코드가 많고, 매번 카테고리를 가져와야 하는 불편함이 있다. header 뿐만 아니라 category가 필요한 곳은 여러곳인데 매번 useState를 써서 fetch 해야하는 것은 번거러움 → 커스텀 훅 작성

    • 커스텀 훅 생성

      // hooks / useCategory.ts
      
      import { useState, useEffect  } from 'react';
      import { Category } from '../models/category.model';
      import { fetchCategory } from '../api/category.api';
      
      export const useCategory = () => {
        const [category, setCategory] = useState<Category[]>([]);
      
        useEffect(() =>{
          fetchCategory().then((category) => {
      
            if(!category) return;
      
            const categoryWithAll = [
              {
                category_id: null,
                category_name: '전체',
              },
              ...category,
            ]
      
            setCategory(categoryWithAll);
          });
        }, []);
      
        return { category };
      }
      // components / common / Header.tsx
      
      import { styled } from 'styled-components';
      import logo from "../../assets/logo.png";
      import { FaSignInAlt, FaRegUser } from "react-icons/fa";
      import { Link } from 'react-router-dom';
      import { useCategory } from '../../hooks/useCategory';
      
      const Header = () => {
        const { category } = useCategory();
      
        return (
          <HeaderStyle>
            <h1 className="logo">
              <Link to="/">
                <img src={logo} alt="book store"/ >
              </Link>
            </h1>
            <nav className="category">
              <ul>
                {
                  category.map((item) => (
                    <li key={item.category_id}>
                      <Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
                        {item.category_name}
                      </Link>
                    </li>
                  ))
                }
              </ul>
            </nav>
      
            ... 생략 ...

✏️ 회원가입 (1)

  • signup 컴포넌트 생성
// pages / Signup.tsx

import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link } from 'react-router-dom';
import { useState } from 'react';

const Signup = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log(email, password);
  }
  return (
    <>
      <Title size="large">회원가입</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' value={email} onChange={(e) => setEmail(e.target.value)} />
          </fieldset>
          <fieldset>
            <InputText placeholder='비밀번호' inputType='password' value={password} onChange={(e) => setPassword(e.target.value)} />
          </fieldset>
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              회원가입
            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}

const SignupStyle = styled.div`
  max-width: ${({theme}) => theme.layout.width.small};
  margin: 80px auto;

  fieldset {
    border: 0;
    padding: 0 0 8px 0;
    .error-text {
      color: red;
    }
  }

  input {
    width: 100%;
  }

  button {
    width: 100%;
  }

  .info {
    text-align: center;
    padding: 16px 0 0 0;
  }

`;

export default Signup;
// components / common / InputText.tsx

import React, { ForwardedRef } from 'react';
import styled from 'styled-components';

interface Props extends React.InputHTMLAttributes<HTMLInputElement>{
  placeholder?: string;
  inputType?: "text" | "email" | "password" | "number";
}

const InputText = React.forwardRef(({ placeholder, inputType, onChange, ...props }: Props, ref: ForwardedRef<HTMLInputElement>) => {
  return(
    <InputTextStyle placeholder={placeholder} ref={ref} type={inputType} onChange={onChange} {...props} />
  );
})

... 생략 ...
// components / common / Button.tsx

import { styled } from 'styled-components'
import { ButtonScheme, ButtonSize } from '../../style/theme';

interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement>{

... 생략 ...
// App.tsx

import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher'; 
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';
import Signup from './pages/Signup';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout><Home /></Layout>,
    errorElement: <Error />
  },
  {
    path: "/books",
    element: <Layout><div>도서 목록</div></Layout>,
    errorElement: <Error />
  },
  {
    path: "/signup",
    element: <Layout><Signup /></Layout>,
  }
]);

... 생략 ...

✏️ 회원가입 (2)

  • 고민이 되는 것 : 필드의 개수만큼 상태의 onChange가 많아질 것임 → 상태 관리 힘들어짐

    • validation 도 복잡해짐
    • react hook form 라이브러리를 통해 관리 할 것임
  • React Hook Form : 리액트의 폼과 validation에 특화된 라이브러리

    • 설치 : npm install react-hook-form
// pages / Signup.tsx

import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { signUp } from '../api/auth.api';

export interface SignupProps {
  email : string;
  password : string;
}

const Signup = () => {
  // const [email, setEmail] = useState("");
  // const [password, setPassword] = useState("");

  // const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  //   event.preventDefault();
  //   console.log(email, password);
  // }

  const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();

  const onSubmit = (data: SignupProps) => {
    signUp(data).then((res) => {
      // 성공
      window.alert('회원 가입이 완료되었습니다.');
    })
  };

  return (
    <>
      <Title size="large">회원가입</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit(onSubmit)}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
            {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
            {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              회원가입
            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}

const SignupStyle = styled.div`
  max-width: ${({theme}) => theme.layout.width.small};
  margin: 80px auto;

  fieldset {
    border: 0;
    padding: 0 0 8px 0;
    .error-text {
      color: red;
    }
  }

  input {
    width: 100%;
  }

  button {
    width: 100%;
  }

  .info {
    text-align: center;
    padding: 16px 0 0 0;
  }

`;

export default Signup;
// api / auth.api.ts

import { SignupProps } from '../pages/Signup';
import { httpClient  } from './http';

export const signUp = async (userData:SignupProps) => {
  const response = await httpClient.post("/users/join", userData);

  return response.data;
}

  • alert 창 말고, 회원가입이 완료되면 로그인 화면 보이게 하기 → navigate 이용
  • window.alert를 바로 쓰면, 나중에 커스텀하거나 수정하기 어려우므로 wrapping 해주기
// pages / Signup.tsx

import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { signUp } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';

export interface SignupProps {
  email : string;
  password : string;
}

const Signup = () => {
  const navigate = useNavigate();
  const showAlert = useAlert();

  const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();

  const onSubmit = (data: SignupProps) => {
    signUp(data).then((res) => {
      // 성공
      showAlert("회원 가입이 완료되었습니다.");
      navigate("/login");
    })
  };

  return (
    <>
      <Title size="large">회원가입</Title>
      <SignupStyle>
        <form onSubmit={handleSubmit(onSubmit)}>
          <fieldset>
            <InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
            {errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
            {errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
          </fieldset>
          <fieldset>
            <Button type="submit" size="medium" scheme="primary">
              회원가입
            </Button>
          </fieldset>
          <div className="info">
            <Link to="/reset">비밀번호 초기화</Link>
          </div>
        </form>
      </SignupStyle>
    </>
  )
}

const SignupStyle = styled.div`
  max-width: ${({theme}) => theme.layout.width.small};
  margin: 80px auto;

  fieldset {
    border: 0;
    padding: 0 0 8px 0;
    .error-text {
      color: red;
    }
  }

  input {
    width: 100%;
  }

  button {
    width: 100%;
  }

  .info {
    text-align: center;
    padding: 16px 0 0 0;
  }

`;

export default Signup;
// hooks / useAlert.ts

import { useCallback } from 'react'

export const useAlert = () => {
  const showAlert = useCallback((message: string) => {
    window.alert(message);
  }, []);

  return showAlert;
}

📍 서버랑 연결할때 에러

프론트 서버는 localhost:3000 이고,
백엔드 서버는 localhost:9999 이어서 발생하는 문제 였음 -> CORS 문제라고함!

// 백엔드 서버쪽
$ npm install cors
// app.js

// express 모듈
const express = require("express");
const app = express();
const cors = require("cors");

// dotenv 모듈
const dotenv = require("dotenv");
dotenv.config();

app.listen(process.env.PORT);

const userRouter = require("./routes/users");
const bookRouter = require("./routes/books");
const categoryRouter = require("./routes/category");
const likeRouter = require("./routes/likes");
const cartRouter = require("./routes/carts");
const orderRouter = require("./routes/orders");
app.use(cors({ origin: "http://localhost:3000", credentials: true }));
app.use("/users", userRouter);
app.use("/books", bookRouter);
app.use("/category", categoryRouter);
app.use("/likes", likeRouter);
app.use("/carts", cartRouter);
app.use("/orders", orderRouter);

참고 자료(다시 읽어보자!): https://velog.io/@seungchan__y/CORS-%EC%97%90%EB%9F%AC%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95
https://coding-maggot.tistory.com/100

📍 useCallback

메모이제이션 기법으로 컴포넌트 성능을 최적화 시켜주는 도구

참고 자료(다시 읽어보자!): https://velog.io/@hjthgus777/React-%EB%8B%A4%EC%8B%9C-%ED%95%9C%EB%B2%88-useCallback%EC%9D%84-%ED%8C%8C%ED%97%A4%EC%B3%90%EB%B3%B4%EC%9E%90

profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글