2024.02.23(금)

🏗️모델 정의

  1. User
    • src/models/user.model.ts
      export interface User {
          id: number;
          email: string;
          password?: string;
      }
  2. Book
    • src/models/book.model.ts
      export interface Book {
          id: number;
          title: string;
          imageId: number;
          summary: string;
          author: string;
          price: number;
          likes: number;
          liked: boolean;
          publishedDate: string;
      }
      
      export interface BookDetail extends Book {
          categoryName: string;
          form: string;
          isbn: string;
          pages: number;
          detail: string;
          tableOfContents: string;
      }
    • src/models/pagination.model.ts
      export interface Pagination {
          currentPage: number;
          totalCount: number;
      }
  3. Category
    • src/models/category.model.ts
      export interface Category {
          id: number | null;
          name: string;
      }
  4. Cart
    • src/models/cart.model.ts
      export interface Cart {
          itemId: number;
          bookId: number;
          title: string;
          summary: string;
          quantity: number;
          price: number;
      }
  5. Order
    • src/models/order.model.ts
      export interface Order {
          orderId: number;
          orderedAt: string;
          address: string;
          recipient: string;
          contact: string;
          bookTitle: string;
          totalQuantity: number;
          totalPrice: number;
      }

📡Axios

node.js와 브라우저를 위한 Promise 기반 HTTP 클라이언트

  • 특징
    • client (browser): XMLHttpRequests 생성
    • server-side(node.js): http request 생성
    • Promise API 지원
    • 요청 및 응답 (then/catch로 넘어가기 전에) 인터셉트
    • 요청 및 응답 데이터 변환
    • 요청 취소
    • 타임아웃
    • request body 자동 직렬화
      • JSON (application/json)
      • Multipart / FormData (multipart/form-data)
      • URL encoded form (application/x-www-form-urlencoded)
    • HTML form을 JSON으로 post 가능
      const {data} = await axios.post('/user', document.querySelector('#my-form'), {
        headers: {
          'Content-Type': 'application/json'
        }
      })
    • 응답에서의 자동 JSON 데이터 처리
    • 브라우저와 node.js를 위한 추가 정보(속도, 남은 시간)를 포함한 progress capturing
    • node.js에서 bandwidth 제한 설정
    • spec-compliant FormData 및 Blob과 호환 가능 (node.js 포함)
    • XSRF를 막기위한 클라이언트 사이드 지원
  • 설치: npm install axios --save
  • Axios API
    • Axios Instance 메소드
      • axios#request(config)
      • axios#get(url[, config])
      • axios#delete(url[, config])
      • axios#head(url[, config])
      • axios#options(url[, config])
      • axios#post(url[, data[, config]])
      • axios#put(url[, data[, config]])
      • axios#patch(url[, data[, config]])
      • axios#getUri([config])
    • 요청 config 객체
      속성설명특징
      url요청을 보낼 서버의 URL상대적인 경로인 경우 baseURL과 함께 사용됨
      method요청 메서드default 'get'
      baseURLURL 앞에 붙일 baseURL상대 경로를 사용할 때 유용
      transformRequest요청 데이터를 서버로 보내기 전에 변경할 수 있는 함수 배열'PUT', 'POST', 'PATCH', 'DELETE' 메서드에서만 적용 가능
      transformResponse응답 데이터가 then/catch로 전달되기 전에 변경할 수 있는 함수 배열
      headers요청 시 보낼 사용자 지정 헤더(custom header)
      params요청과 함께 보낼 URL parameters반드시 일반 객체나 URLSearchParams 객체여야 함
      null이나 undefined는 URL에 렌더링되지 않음
      paramsSerializerparams의 직렬화를 담당하는 optional 함수
      datarequest body로 보낼 데이터'PUT', 'POST', 'PATCH', 'DELETE' 메서드에서만 적용 가능
      timeout요청이 타임아웃되기 전에 대기할 시간 [ms]default 0 (no timeout)
      요청이 timeout보다 오래 걸리면 중단됨
      withCredentialscross-site Access-Control 요청에 자격 증명을 사용할지 여부default false
      adapter요청 처리를 커스터마이징하는 데 사용되는 함수lib/adapters/README.md 참고
      authHTTP Basic 인증을 사용하고 해당 자격 증명을 제공headers를 사용하여 설정한 기존의 Authorization 사용자 지정 헤더를 덮어씀
      Bearer 토큰 등의 경우 Authorization 사용자 지정 헤더를 대신 사용
      responseType서버의 응답으로 받을 데이터 유형옵션: 'arraybuffer', 'document', 'json', 'text', 'stream'
      브라우저 전용: 'blob'
      responseEncoding응답 디코딩에 사용할 인코딩Node.js 전용
      client-side 요청 또는 responseType이 'stream'이면 무시됨
      xsrfCookieNameXSRF 토큰 값으로 사용할 쿠키의 이름default 'XSRF-TOKEN'
      xsrfHeaderNameXSRF 토큰 값을 운반하는 HTTP 헤더의 이름default 'X-XSRF-TOKEN'
      onUploadProgress업로드 진행 상황을 처리하는 함수브라우저 전용
      onDownloadProgress다운로드 진행 상황을 처리하는 함수브라우저 전용
      maxContentLengthHTTP 응답 본문의 최대 크기 [byte]Node.js 전용
      maxBodyLengthHTTP 요청 본문의 최대 크기 [byte]Node.js 전용
      validateStatusHTTP 응답 상태 코드의 유효성을 확인하는 함수default Promise가 2XX HTTP 상태코드일 때 resolve, 그 외는 reject
      maxRedirects리디렉션 최대값default 5
      socketPathnode.js에서 사용될 UNIX 소켓을 정의default null
      socketPath 또는 proxy만 지정 가능 (둘 다 지정될 경우 socketPath가 사용됨)
      httpAgentHTTP 요청에 사용되는 사용자 지정 에이전트(custom agent)Node.js 전용
      기본적으로 활성화되지 않은 keepAlive와 같은 옵션 추가 가능
      httpsAgentHTTPS 요청에 사용되는 사용자 지정 에이전트(custom agent)Node.js 전용
      기본적으로 활성화되지 않은 keepAlive와 같은 옵션 추가 가능
      proxyproxy server의 hostname, port, protocol 정의
      cancelToken요청을 취소하는 데 사용되는 토큰
      decompressresponse body의 자동 압축 해제 여부default true
      Node.js 전용
      (추후 Error handling을 하려면 validateStatus를 변경해야 할 것 같다.)
    • 응답 response 객체
      속성설명특징
      dataserver에서 제공된 응답 데이터
      status서버 응답의 HTTP 상태 코드
      statusText서버 응답의 HTTP 상태 메시지
      headers서버 응답의 HTTP 헤더모든 헤더 이름은 소문자로 표시
      Content-Type 헤더는 response.headers['content-type']으로 접근 가능
      configAxios로 요청을 보낼 때 제공된 config 객체
      request해당 응답을 생성한 요청 정보node.js: 마지막 ClientRequest 인스턴스
      브라우저: 마지막 XMLHttpRequest 인스턴스

⚠️Cross-Origin Resource Sharing (CORS) Error

Access to XMLHttpRequest at 'http://localhost:7777/api/category' from origin 'http://localhost:3000' has been blocked by CORS policy

SOP (Same Origin Policy, 동일 출처 정책)

  • 웹에서는 출처가 같은 자원끼리만 상호작용 가능
  • 동일 출처 = protocol, host, port 동일

CORS (Cross Origin Resource Sharing, 교차 출처 리소스 공유)

  • HTTP-header based mechanism
  • 한 출처(클라이언트)에서 다른 출처의 자원이 필요한 경우, 서버가 해당 자원에 접근할 수 있는 권한을 부여해야 함
  • HTTP cross-origin 요청을 했을 때 서버가 동의를 하면 브라우저에서 요청을 허락, 동의하지 않으면 브라우저에서 요청을 거절하는 시스템
  • CORS Error는 브라우저가 보내는 것!!

CORS Error 해결 방법

  • Client-side (React)
    • 프록시 서버(Proxy Server) 사용
    • 프록시 서버란?
      • 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해 주는 서버
      • 서버로 가기 전에 프록시 서버를 거쳐서 출처를 response와 같게 수정 & Server에 접근하도록 함 (출처를 동일하게 만들어주기)
    • 근데 프록시 서버는 개발 환경에서만 사용 가능해서 서버 코드를 직접 수정할 수 없는 경우에는 Netlify proxy server를 세팅하면 되는 것 같음
  • Server-side (Node.js express 서버) npm install cors
    // app.js
    
    const cors = require('cors');
    
    app.use(cors({
      origin: 'http://localhost:3000',
      credentials: true
    }));

🏷️카테고리

  • 공통 axios instance 설정

    • src/api/http.ts
      import axios, { AxiosRequestConfig } from 'axios';
      
      const BASE_URL = "http://localhost:7777/api";
      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 => response,
              error => Promise.reject(error)
          );
      
          return axiosInstance;
      };
      
      export const httpClient = createClient();
  • category

    • src/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;
      };
    • src/hooks/useCategory.ts

      • Custom Hook

        컴포넌트 간에 stateful logic을 공유할 수 있음! (state 자체를 공유하려면 props 사용) ⇒ 반복되는 stateful logic을 줄일 수 있음

        import { useEffect, useState } 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 = [
                            {
                                id: null,
                                name: "전체"
                            },
                            ...category
                        ];
        
                        setCategory(categoryWithAll);
                    });
            }, []);
        
            return { category };
        };
    • src/components/common/Header.tsx

      • 기존에 category 배열 대신 api에 요청을 보내 category 배열을 받아서 사용

      • useCategory라는 Custom Hook을 사용해서 category 배열을 받아옴

        import styled from "styled-components";
        import logo from "../../assets/images/logo.png"
        import { FaSignInAlt, FaRegUser } from "react-icons/fa"
        import ThemeSwitcher from "../header/ThemeSwitcher";
        import { Link } from "react-router-dom";
        import { useCategory } from "../../hooks/useCategory";
        
        function 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.id}>
                                        <Link to={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
                                            {item.name}
                                        </Link>
                                    </li>
                                ))
                            }
                        </ul>
                    </nav>
                    <nav className="auth">
                        <ul>
                            <li>
                                <ThemeSwitcher />
                            </li>
                            <li>
                                <Link to="/login">
                                    <FaSignInAlt />로그인
                                </Link>
                            </li>
                            <li>
                                <Link to="/signup">
                                    <FaRegUser />회원가입
                                </Link>
                            </li>
                        </ul>
                    </nav>
                </HeaderStyle>
            );
        }
        
        const HeaderStyle = styled.header`
            width: 100%;
            margin: 0 auto;
            max-width: ${({ theme }) => theme.layout.width.large};
        
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 20px 0;
            border-bottom: 1px solid ${({ theme }) => theme.color.background};
        
            .logo {
                img {
                    width: 200px;
                }
            }
        
            .category {
                ul {
                    display: flex;
                    gap: 32px;
                    li {
                        a {
                            font-size: 1.5rem;
                            font-weight: 600;
                            text-decoration: none;
                            color: ${({ theme }) => theme.color.text};
        
                            &:hover {
                                color: ${({ theme }) => theme.color.primary};
                            }
                        }
                    }
                }
            }
        
            .auth {
                ul {
                    display: flex;
                    gap: 16px;
                    li {
                        a {
                            font-size: 1rem;
                            font-weight: 600;
                            text-decoration: none;
                            display: flex;
                            align-items: center;
                            line-height: 1;
        
                            svg {
                                margin-right: 6px;
                            }
                        }
                    }
                }
            }
        `;
        
        export default Header;

🔐회원가입

  • 테스트 메일: react.baby@gmail.com / reactbaby

  • 공통 컴포넌트에서 html tag의 속성을 사용할 수 있도록 수정

    • src/components/common/InputText.tsx
      • 기존 Props interface가 React.InputHTMLAttributes<HTMLInputElement>를 extend하도록 수정 → input tag의 속성을 사용할 수 있게 됨!

        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} />
            );
        });
        
        const InputTextStyle = styled.input`
            padding: 0.25rem 0.75rem;
            border: 1px solid ${({ theme }) => theme.color.border};
            border-radius: ${({ theme }) => theme.borderRadius.default};
            font-size: 1rem;
            line-height: 1.5;
            color: ${({ theme }) => theme.color.text};
        `;
        
        export default InputText;
    • src/components/common/Button.tsx
      • 기존 Props interface가 React.ButtonHTMLAttributes<HTMLButtonElement>를 extend하도록 수정 → button tag의 속성을 사용할 수 있게 됨!

        import styled from "styled-components";
        import { ButtonScheme, ButtonSize } from "../../style/theme";
        
        interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
            children: React.ReactNode;
            size: ButtonSize;
            scheme: ButtonScheme;
            disabled?: boolean;
            isLoading?: boolean;
        }
        
        function Button({ children, size, scheme, disabled, isLoading }: Props) {
            return (
                <ButtonStyle size={size} scheme={scheme} disabled={disabled} isLoading={isLoading}>
                    {children}
                </ButtonStyle>
            );
        }
        
        const ButtonStyle = styled.button<Omit<Props, "children">>`
            font-size: ${({ theme, size }) => theme.button[size].fontSize};
            padding: ${({ theme, size }) => theme.button[size].padding};
            color: ${({ theme, scheme }) => theme.buttonScheme[scheme].color};
            background-color: ${({ theme, scheme }) => theme.buttonScheme[scheme].backgroundColor};
            border: 0;
            border-radius: ${({ theme }) => theme.borderRadius.default};
            opacity: ${({ disabled }) => disabled ? 0.5 : 1};
            pointer-events: ${({ disabled }) => disabled ? "none" : "auto"};
            cursor: ${({ disabled }) => disabled ? "none" : "pointer"};
        `;
        
        export default Button;
  • signup

    • src/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/signup", userData);
          return response.data;
      };
    • src/hooks/useAlert.ts

      • useCallback

        리렌더링 사이에 함수 정의를 캐시할 수 있게 해주는 React Hook

        useCallback(fn, dependencies)
        • fn: 캐시하려는 함수 값
          • React는 초기 렌더링을 하는 동안 함수를 반환(호출❌)
          • 다음 렌더링에서 React는 마지막 렌더링 이후 dependencies가 변경되지 않았다면 동일한 함수를 다시 제공
        import { useCallback } from "react";
        
        export const useAlert = () => {
            const showAlert = useCallback((message: string) => {
                window.alert(message);
            }, []);
        
            return showAlert;
        };
    • src/pages/Signup.tsx

      • React Hook Form: npm install react-hook-form --save

        const { register, handleSubmit, formState } = useForm();
      • useForm Hook과 useAlert custom Hook 사용

      • useNavigate Hook을 사용해서 회원가입 성공 시 로그인 페이지로 이동

      • <fieldset></fieldset> 태그

        <form> 요소에서 연관된 요소들을 하나의 그룹으로 묶을 때 사용

        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 { useState } from "react";
        import { signup } from "../api/auth.api";
        import { useAlert } from "../hooks/useAlert";
        
        export interface SignupProps {
            email: string;
            password: string;
        }
        
        function Signup() {
            const navigate = useNavigate();
            const showAlert = useAlert();
            // const [email, setEmail] = useState("");
            // const [password, setPassword] = useState("");
        
            // const handleSubmit = ((event: React.FormEvent<HTMLFormElement>) => {
            //     event.preventDefault(); // action에 의한 페이지 이동(새로고침) 방지
            // });
        
            const {
                register,
                handleSubmit,
                formState: { errors }
            } = useForm<SignupProps>();
        
            const onSubmit = (data: SignupProps) => {
                signup(data).then(() => {
                    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>
                </>
            );
        }
        
        export 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;
    • src/App.tsx

      • router에 Signup 컴포넌트 추가

        import Signup from "./pages/Signup";
        
        const router = createBrowserRouter([,
          {
            path: "/signup",
            element: <Layout><Signup /></Layout>
          },
        ]);

🔄️비밀번호 초기화

  • signup과 비슷
  • reset-password
    • src/api/auth.api.ts
      • resetRequestresetPassword 추가

        import { SignupProps } from "../pages/Signup";
        import { httpClient } from "./http";
        
        export const signup = async (userData: SignupProps) => {
            const response = await httpClient.post("/users/signup", userData);
            return response.data;
        };
        
        export const resetRequest = async (data: SignupProps) => {
            const response = await httpClient.post("/users/reset-password", data);
            return response.data;
        };
        
        export const resetPassword = async (data: SignupProps) => {
            const response = await httpClient.put("/users/reset-password", data);
            return response.data;
        };
    • src/pages/ResetPassword.tsx
      • signup의 style을 그대로 사용

        import { useForm } from "react-hook-form";
        import Title from "../components/common/Title";
        import InputText from "../components/common/InputText";
        import Button from "../components/common/Button";
        import { useNavigate } from "react-router-dom";
        import { useState } from "react";
        import { resetPassword, resetRequest } from "../api/auth.api";
        import { useAlert } from "../hooks/useAlert";
        import { SignupStyle } from "./Signup";
        
        export interface SignupProps {
            email: string;
            password: string;
        }
        
        function ResetPassword() {
            const navigate = useNavigate();
            const showAlert = useAlert();
            const [resetRequested, setResetRequested] = useState(false);
            
            const {
                register,
                handleSubmit,
                formState: { errors }
            } = useForm<SignupProps>();
        
            const onSubmit = (data: SignupProps) => {
                if (resetRequested) {
                    resetPassword(data).then(() => {
                        showAlert("비밀번호 초기화 완료");
                        navigate("/login");
                    });
                } else {
                    resetRequest(data).then(() => {
                        setResetRequested(true);
                    });
                }
            };
        
            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>
                            {
                                resetRequested && (
                                    <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">
                                    {resetRequested ? "비밀번호 초기화" : "초기화 요청"}
                                </Button>
                            </fieldset>
                        </form>
                    </SignupStyle>
                </>
            );
        }
        
        export default ResetPassword;
    • src/App.tsx
      • router에 ResetPassword 컴포넌트 추가

        import ResetPassword from "./pages/ResetPassword";
        
        const router = createBrowserRouter([,
          {
            path: "/reset",
            element: <Layout><ResetPassword /></Layout>
          },
        ]);

🚪로그인과 전역상태

  • signup과 비슷
  • 전역 상태 관리를 위해🐻Zustand 사용: npm install zustand --save
  • Zustand Store 생성
    • src/store/authStore.ts
      • localStorage 활용

        import { create } from "zustand";
        
        interface StoreState {
            isLoggedIn: boolean;
            storeLogin: (token: string) => void;
            storeLogout: () => void;
        }
        
        export const getToken = () => {
            const token = localStorage.getItem("token");
            return token;
        };
        
        const setToken = (token: string) => {
            localStorage.setItem("token", token);
        };
        
        export const removeToken = () => {
            localStorage.removeItem("token");
        };
        
        export const useAuthStore = create<StoreState>(set => ({
            isLoggedIn: Boolean(getToken()),
            storeLogin: (token: string) => {
                set({isLoggedIn: true});
                setToken(token);
            },
            storeLogout: () => {
                set({isLoggedIn: false});
                removeToken();
            }
        }));
  • reset-password
    • src/api/auth.api.ts
      • LoginResponse interface와 login 추가

        import { SignupProps } from "../pages/Signup";
        import { httpClient } from "./http";interface LoginResponse {
            accessToken: string;
        }
        
        export const login = async (userData: SignupProps) => {
            const response = await httpClient.post<LoginResponse>("/users/login", userData);
            return response.data;
        };
    • src/pages/Login.tsx
      • signup의 style을 그대로 사용

      • Zustand로 구성한 store 사용

        import { useForm } from "react-hook-form";
        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 { login } from "../api/auth.api";
        import { useAlert } from "../hooks/useAlert";
        import { SignupStyle } from "./Signup";
        import { useAuthStore } from "../store/authStore";
        
        export interface SignupProps {
            email: string;
            password: string;
        }
        
        function Login() {
            const navigate = useNavigate();
            const showAlert = useAlert();
        
            const { storeLogin } = useAuthStore();
        
            const {
                register,
                handleSubmit,
                formState: { errors }
            } = useForm<SignupProps>();
        
            const onSubmit = (data: SignupProps) => {
                login(data)
                    .then((res) => {
                        storeLogin(res.accessToken);
                        showAlert("로그인 완료");
                        navigate("/");
                    })
                    .catch((error) => {
                        showAlert("로그인 실패");
                    });
            };
        
            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>
                </>
            );
        }
        
        export default Login;
    • src/App.tsx
      • router에 Login 컴포넌트 추가

        import Login from "./pages/Login";
        
        const router = createBrowserRouter([,
          {
            path: "/login",
            element: <Layout><Login /></Layout>
          },
        ]);
    • src/components/common/Header.tsx
      • Zustand store의 전역 값(isLoggedIn)에 따라 보여지는 화면이 달라지도록 코드 추가

        import styled from "styled-components";
        import logo from "../../assets/images/logo.png"
        import { FaSignInAlt, FaRegUser, FaShoppingCart, FaTruck, FaSignOutAlt } from "react-icons/fa"
        import ThemeSwitcher from "../header/ThemeSwitcher";
        import { Link } from "react-router-dom";
        import { useCategory } from "../../hooks/useCategory";
        import { useAuthStore } from "../../store/authStore";
        
        function Header() {
            const { category } = useCategory();
            const { isLoggedIn, storeLogout } = useAuthStore();
        
            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>
                    <nav className="auth">
                        {
                            isLoggedIn && (
                                <ul>
                                    <li>
                                        <ThemeSwitcher />
                                    </li>
                                    <li>
                                        <Link to="/cart">
                                            <FaShoppingCart />장바구니
                                        </Link>
                                    </li>
                                    <li>
                                        <Link to="/orderlist">
                                            <FaTruck />주문내역
                                        </Link>
                                    </li>
                                    <li>
                                        <button onClick={storeLogout}>
                                            <FaSignOutAlt />로그아웃
                                        </button>
                                    </li>
                                </ul>
                            )
                        }
                        {
                            !isLoggedIn && (
                                <ul>
                                    <li>
                                        <ThemeSwitcher />
                                    </li>
                                    <li>
                                        <Link to="/login">
                                            <FaSignInAlt />로그인
                                        </Link>
                                    </li>
                                    <li>
                                        <Link to="/signup">
                                            <FaRegUser />회원가입
                                        </Link>
                                    </li>
                                </ul>
                            )
                        }
                    </nav>
                </HeaderStyle>
            );
        }
        
        const HeaderStyle = styled.header`
            width: 100%;
            margin: 0 auto;
            max-width: ${({ theme }) => theme.layout.width.large};
        
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 20px 0;
            border-bottom: 1px solid ${({ theme }) => theme.color.background};
        
            .logo {
                img {
                    width: 200px;
                }
            }
        
            .category {
                ul {
                    display: flex;
                    gap: 32px;
                    li {
                        a {
                            font-size: 1.5rem;
                            font-weight: 600;
                            text-decoration: none;
                            color: ${({ theme }) => theme.color.text};
        
                            &:hover {
                                color: ${({ theme }) => theme.color.primary};
                            }
                        }
                    }
                }
            }
        
            .auth {
                ul {
                    display: flex;
                    gap: 16px;
                    li {
                        a, button {
                            font-size: 1rem;
                            font-weight: 600;
                            text-decoration: none;
                            display: flex;
                            align-items: center;
                            line-height: 1;
                            background: none;
                            border: 0;
                            cursor: pointer;
        
                            svg {
                                margin-right: 6px;
                            }
                        }
                    }
                }
            }
        `;
        
        export default Header;
    • src/api/http.ts
      • Authorization header를 추가하고 401 error 발생 시 login 화면으로 보내기

        import axios, { AxiosRequestConfig } from 'axios';
        import { getToken, removeToken } from '../store/authStore';
        
        const BASE_URL = "http://localhost:7777/api";
        const DEFAULT_TIMEOUT = 30000;
        
        export const createClient = (config?: AxiosRequestConfig) => {
            const axiosInstance = axios.create({
                baseURL: BASE_URL,
                timeout: DEFAULT_TIMEOUT,
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `Bearer ${getToken() ?? ""}`
                },
                withCredentials: true,
                ...config
            });
        
            axiosInstance.interceptors.response.use(
                response => response,
                error => {
                    if (error.response.status === 401) {
                        removeToken();
                        window.location.href = "/login";
                        return;
                    }
                    Promise.reject(error);
                }
            );
        
            return axiosInstance;
        };
        
        export const httpClient = createClient();

나는 백엔드 스프린트 때 개인적으로 accessToken & refreshToken 방식으로 구현했고, 현재 서버에서 refreshToken은 cookie에 accessToken은 response body에 담아서 보내도록 해두었다. 그래서 원래는 클라이언트에서 인증이 필요한 요청을 보냈을 때 에러(401)가 발생하면 /refresh api로 요청을 보내 다시 refreshToken을 발급받아 원래 요청을 다시 보내고, 만약 /refresh api 요청에서도 에러(400)가 발생하면 로그인 페이지로 redirect하도록 하는 방식을 생각하고 있었다. 강의에서 짜준 코드처럼 status code만으로는 이런 상황을 구분하기 어려울 것 같고 message까지 확인해서 그에 맞는 로직을 짜야할 것 같다. 서버쪽 Error class 부분을 정리하고 어떻게 구현할지 생각해봐야겠다.

profile
이것저것 관심 많은 개발자👩‍💻

0개의 댓글