14 분 소요


[기초 소스코드↗️]

1. 세팅

1.1 json-server 사용

package.json 파일의 scripts 섹션에 아래 내용 추가하기

"scripts": {
  "serve": "json-server --watch db.json --port 4000"
}


아래 명령을 통해 json-sever를 실행

yarn serve


1.2 파일 구조



2. Router 작성

2.1 Router 연결

요구사항

  1. 라우팅은 “중첩 라우팅”으로 작성할 것!
  2. 로그인 여부가 상관없는(로그인해도, 하지 않아도 접근할 수 있는) 페이지는 Layout.jsx를 공통 element로 사용하기
    1. 대상: Home, SearchPage, TestPage
  3. 로그인 상태가 반드시 아니어야 하는(로그인 한 상태면 접근할 수 없는) 페이지는 NonAuthLayout.jsx를 공통 element로 사용하기
    1. 대상: LoginPage, SignupPage
  4. 로그인이 반드시 필요한(로그인 해야만 접근할 수 있는) 페이지는 AuthLayout.jsx를 공통 element로 사용하기
    1. 대상: UserProfile
  5. 어느것에도 포함되지 않는 페이지의 경우 NotFound(src > pages > default-set > NotFound.jsx)를 렌더링하기
// src/shared/Router.jsx
import AuthLayout from "components/layouts/AuthLayout";
import Layout from "components/layouts/Layout";
import NonAuthLayout from "components/layouts/NonAuthLayout";
import NotFound from "pages/default-set/NotFound";
import HomePage from "pages/protected/HomePage";
import UserProfilePage from "pages/protected/UserProfilePage";
import PublicHomePage from "pages/public/PublicHomePage";
import SearchPage from "pages/public/SearchPage";
import SignInPage from "pages/public/SignInPage";
import SignUpPage from "pages/public/SignUpPage";
import TestPage from "pages/public/TestPage";
import { BrowserRouter, Route, Routes } from "react-router-dom";

const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        {/* 로그인 여부 상관없는 라우터 */}
        <Route element={<Layout />}>
          <Route path="/" element={<PublicHomePage />} />
          <Route path="sign-in" element={<SignInPage />} />
          <Route path="sign-up" element={<SignUpPage />} />
          <Route path="search" element={<SearchPage />} />
          <Route path="test" element={<TestPage />} />
        </Route>

        {/* 로그인 상태가 반드시 아니어야 하는 라우터 */}
        <Route element={<NonAuthLayout />}></Route>

        {/* 로그인이 필요한 라우터 */}
        <Route element={<AuthLayout />}>
          <Route path="/" element={<HomePage />} />
          <Route path="/user/:userId" element={<UserProfilePage />} />
        </Route>

        {/* 404 Not Found */}
        <Route path="*" element={<NotFound />} />

        {/* 사용자가 잘못된 경로로 이동했을 때 기본적으로 (/)로 리다이렉션 */}
        {/* <Route path="*" element={<Navigate replace to="/" />} /> */}
      </Routes>
    </BrowserRouter>
  );
};

export default Router;


2.2 Outlet 연결

각 공통 element(Layout, AuthLayout, NonAuthLayout)는 Outlet을 가지고 있어서 중첩된 컴포넌트가 Layout을 적용받은 상태에서 렌더링 될 수 있도록 하기

2.2.1 Layout.jsx

// src/styles/CommonStyles.jsx
import styled from "styled-components";

export const CommonContainer = styled.div`
  display: flex;
  max-width: 1200px;
  margin: 0 auto;
`;
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const Layout = () => {
  return (
    <>
      <Navbar />

      <StLayout>
        <StContainer>
          <StMessage>
            로그인 여부와 상관없이 접근 가능한 페이지입니다.
          </StMessage>
          <StMain>
            <Outlet />
          </StMain>
        </StContainer>
      </StLayout>
    </>
  );
};

export default Layout;

const StLayout = styled.nav`
  background-color: #e2e2e2;
  border-bottom: 1px solid #ddd;
`;

const StContainer = styled(CommonContainer)`
  flex-direction: column;
  min-height: 100vh;
`;

const StMessage = styled.p`
  text-align: center;
  margin: 20px 0;
  font-size: 18px;
  color: #333;
`;

const StMain = styled.main`
  flex: 1;
  padding: 20px;
`;


2.2.2 AuthLayout.jsx

import { Navigate, Outlet } from "react-router-dom";
import Navbar from "./Navbar";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const AuthLayout = () => {
  return (
    <>
      <Navbar />

      <StLayout>
        <StContainer>
          <StMessage>반드시 로그인이 되어있어야 하는 페이지입니다.</StMessage>
          <StMain>
            <Outlet />
          </StMain>
        </StContainer>
      </StLayout>
    </>
  );
};

export default AuthLayout;

const StLayout = styled.nav`
  background-color: #e2e2e2;
  border-bottom: 1px solid #ddd;
`;

const StContainer = styled(CommonContainer)`
  flex-direction: column;
  min-height: 100vh;
`;

const StMessage = styled.p`
  text-align: center;
  margin: 20px 0;
  font-size: 18px;
  color: #333;
`;

const StMain = styled.main`
  flex: 1;
  padding: 20px;
`;


2.2.3 NonAuthLayout.jsx

import { Navigate, Outlet } from "react-router-dom";
import Navbar from "./Navbar";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const NonAuthLayout = () => {
  return (
    <>
      <Navbar />

      <StLayout>
        <StContainer>
          <StMessage>
            로그인이 반드시 안 되어 있어야 하는 페이지입니다.
          </StMessage>
          <StMain>
            <Outlet />
          </StMain>
        </StContainer>
      </StLayout>
    </>
  );
};

export default NonAuthLayout;

const StLayout = styled.nav`
  background-color: #e2e2e2;
  border-bottom: 1px solid #ddd;
`;

const StContainer = styled(CommonContainer)`
  flex-direction: column;
  min-height: 100vh;
`;

const StMessage = styled.p`
  text-align: center;
  margin: 20px 0;
  font-size: 18px;
  color: #333;
`;

const StMain = styled.main`
  flex: 1;
  padding: 20px;
`;



3. custom axios 작성

3.1 auth.js

요구사항

  1. authApi 생성
    1. 기본url을 https://moneyfulpublicpolicy.co.kr/ 로 설정 «««< HEAD
    2. haeders는 "Content-Type": "application/json" 로 설정

    3. haeders는 “Content-Type”: “application/json” 로 설정

      93a4303f45c58407b5cb25a65a107bcdc2c39301

  2. 요청 interceptor 설정
    1. request interceptor에서 요청하는 url이 “user”인 경우는 localStorage에 있는 accessToken을 header에 포함시킴
    2. 이 경우, accessToken이 존재하지 않는다면 아래 코드로 처리하여 실패 동작

      alert("인증이 필요합니다.");
      return Promise.reject("인증이 필요합니다.");
      



config를 console에 찍으면 사용자 요청에 대한 url이 나온다.

authApi.interceptors.request.use(
  (config) => {
    console.log(config);
    return config;
  },
  () => {}
);


즉, 이 url을 활용하여 access token check를 할 수 있다!

import axios from "axios";

export const authApi = axios.create({
  baseURL: "https://moneyfulpublicpolicy.co.kr",
  headers: {
    "Content-Type": "application/json",
  },
});

// 인터셉터 설정
authApi.interceptors.request.use(
  (config) => {
    console.log("authApi config:", config);

    // 유저가 가지고 있는 accessToken이 지금 요청을 보내려고 하는 이 시점에 맞아?? 정확해?? 유효기간 있어?? 를 파악해야한다!
    const token = localStorage.getItem("accessToken");

    if (config.url === "/user") {
      if (token) {
        // Bearer은 소유자라는 뜻인데, "이 토큰의 소유자에게 권한을 부여해줘"라는 의미
        // Bearer뒤에 있는 것을 accessToken으로 사용

        // ( config.headers.Authorization = )과 동일
        config.headers["Authorization"] = `Bearer ${token}`;
      } else {
        alert("인증이 필요합니다.");
        return Promise.reject("인증이 필요합니다! 오류가 발생했어요.");
      }
    }

    // url에 따른 분기
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);


3.2 posts.js

요구사항

⚠️ postsAxios는 권한이 필요 없는 요청이라 가정한다. 따라서 요청 interceptor를 별도로 작성하지 않는다.

  1. 기본 url을 json-server 주소로 설정 (ex : http://localhost:4001/posts)
  2. timeout을 1.5초로 세팅
import axios from "axios";

const postsAxios = axios.create({
  baseURL: `${process.env.REACT_APP_SERVER_URL}/posts`,
  timeout: 1500,
});

export default postsAxios;


3.3 comments.js

요구사항

  1. commentsAxios 생성
    1. commentsAxios는 권한이 반드시 필요한 요청이라 가정한다. 따라서 요청 interceptor를 작성하여 사전에 user의 권한을 파악한다.
    2. 기본 url을 json-server 주소(localhost:3001번 권장)/comments 설정 (ex : http://localhost:3001/comments)
    3. timeout을 1.5초로 세팅
  2. 요청 interceptor 설정
    1. custom으로 만든 authApi에 user 요청(하단 인증 API 명세서 - 회원정보 확인 참고)을 통해 검증을 진행 - 반드시 authApi를 이용
  3. 응답 interceptor 설정
    1. 요청이 성공 응답을 받은 경우, console.log("요청 성공입니다.")를 출력


  • 버튼이 눌리는 시점에 권한이 있는지 파악해야한다.
  • 이를 위해 onClick을 사용하는 것 보다 commnets 요청에 request 부분에서 로직을 작성해 주는 것이 좋다.
import axios from "axios";
import { authApi } from "./auth";

const commentsAxios = axios.create({
  baseURL: `${process.env.REACT_APP_SERVER_URL}/comments`,
  timeout: 1500,
});

commentsAxios.interceptors.request.use(
  async (config) => {
    // 유저가 가지고 있는 accessToken이 지금 요청을 보내려고 하는 이 시점에 맞아?? 정확해?? 유효기간 있어?? 를 파악해야한다!
    const ac = localStorage.getItem("accessToken");
    try {
      // (1) 유저 권한 체크 to 백엔드
      await axios.get("/user", {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${ac}`,
        },
      });
      return config; // 성공
    } catch (error) {
      return Promise.reject(error); // 비동기 작업 중 발생한 에러 반환
    }
  },
  (error) => {
    return Promise.reject(error); // 요청 설정 오류 반환
  }
);

commentsAxios.interceptors.response.use((response) => {
  console.log("요청 성공입니다.");
  return response;
});

export default commentsAxios;


리팩토링

  • auth.js 와 코드와 중복되니 auth.js에 존재하는 user를 사용하도록 하자
  • 이렇게 되면 accessToken과 headers 세팅이 필요 없어진다!
  • 아래 처럼 comments.js에서 user을 넘기면 auth.js가 분기를 해준다.
import axios from "axios";
import { authApi } from "./auth";

const commentsAxios = axios.create({
  baseURL: `${process.env.REACT_APP_SERVER_URL}/comments`,
  timeout: 1500,
});

commentsAxios.interceptors.request.use(
  async (config) => {
    try {
      // (1) 유저 권한 체크 to 백엔드
      await authApi.get(`/user`);
      return config; // 성공
    } catch (error) {
      return Promise.reject(error); // 비동기 작업 중 발생한 에러 반환
    }
  },
  (error) => {
    return Promise.reject(error); // 요청 설정 오류 반환
  }
);

commentsAxios.interceptors.response.use((response) => {
  console.log("요청 성공입니다.");
  return response;
});

export default commentsAxios;


왼쪽 comment.js가 통과했다는 이야기는 이미 auth.js가 통과했다는 이야기임!



4. 로그인 여부 상관없는 페이지

4.1 회원가입(SignupPage.jsx)

요구사항

  1. 회원가입 성공 시
    1. alert로 “회원가입에 성공하였습니다. 로그인 페이지로 이동할게요” 메시지 출력
    2. login 페이지로 자동 이동
  2. 회원가입 실패 시
    1. alert로 백엔드에서 제공받은 오류 메시지 출력
    2. console.log로 오류 내용 전체 출력
// src/pages/public/SignUpPage.jsx
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { authApi } from "../../axios/auth";
import styled from "styled-components";

const SignUpPage = () => {
  const idRef = useRef(null);
  const pwRef = useRef(null);
  const nickNameRef = useRef(null);

  const navigate = useNavigate();

  useEffect(() => {
    idRef.current.focus();
  }, []);

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      await authApi.post(`/register`, {
        id: idRef.current.value,
        password: pwRef.current.value,
        nickname: nickNameRef.current.value,
      });
      // 성공 시
      alert("회원가입이 완료되었습니다.");
      navigate("/login");
    } catch (error) {
      // 실패 시
      const message = error.response.data.message;
      alert(message || "회원가입 중 문제가 발생했습니다."); // 사용자 확인
      console.log(message); // 개발자 확인
    }
  };

  return (
    <StContainer>
      <StTitle>회원가입 창</StTitle>
      <StForm onSubmit={handleSubmit}>
        <StLabel htmlFor="id">아이디</StLabel>
        <StInput
          type="text"
          name="id"
          ref={idRef}
          placeholder="아이디를 입력하세요."
        />
        <StLabel htmlFor="nickname">닉네임</StLabel>
        <StInput
          type="text"
          id="nickname"
          ref={nickNameRef}
          placeholder="닉네임을 입력하세요."
        />
        <StLabel htmlFor="password">비밀번호</StLabel>
        <StInput
          type="password"
          id="password"
          ref={pwRef}
          placeholder="비밀번호를 입력하세요."
        />
        <StButton type="submit">회원가입</StButton>
        <StButton
          type="button"
          onClick={() => {
            navigate("/sign-in");
          }}
          style={{ backgroundColor: "#6c757d", borderColor: "#6c757d" }}
        >
          로그인하러 가기
        </StButton>
      </StForm>
    </StContainer>
  );
};

const StContainer = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 2rem;
  max-width: 500px;
  margin: auto;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  background-color: #f9f9f9;
`;

const StTitle = styled.h1`
  margin-bottom: 1.5rem;
  font-size: 2rem;
  color: #333;
`;

const StForm = styled.form`
  display: flex;
  flex-direction: column;
  width: 100%;
`;

const StLabel = styled.label`
  font-size: 1rem;
  margin-bottom: 0.5rem;
  color: #555;
`;

const StInput = styled.input`
  padding: 0.8rem;
  margin-bottom: 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
`;

const StButton = styled.button`
  padding: 0.8rem;
  margin: 0.5rem 0;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  color: #fff;
  background-color: #007bff;
  cursor: pointer;
  transition: background-color 0.3s ease;

  &:hover {
    background-color: #0056b3;
  }
`;

export default SignUpPage;


4.2 로그인 기능(SignInPage.jsx)

요구사항

  1. 로그인 성공 시
    1. alert로 “로그인에 성공하였습니다. 로그인 페이지로 이동할게요” 메시지 출력
    2. localStorage에 저장(accessToken, userId, nickname)
    3. 메인 페이지(Home 컴포넌트, 경로상 “/”)로 자동 이동
  2. 로그인 실패 시
    1. alert로 백엔드에서 제공받은 오류 메시지 출력
    2. console.log로 오류 내용 전체 출력
// src/pages/public/SignInPage.jsx
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { authApi } from "../../axios/auth";
import styled from "styled-components";

const SignInPage = () => {
  const idRef = useRef(null);
  const pwRef = useRef(null);

  const navigate = useNavigate();

  useEffect(() => {
    idRef.current.focus();
  }, []);

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      const res = await authApi.post("/login", {
        id: idRef.current.value,
        password: pwRef.current.value,
      });
      const { accessToken, avatar, nickname, userId } = res.data;

      // 로그인 성공 시
      alert("로그인에 성공하셨습니다. 홈으로 이동합니다.");

      localStorage.setItem("accessToken", accessToken);
      localStorage.setItem("avatar", avatar);
      localStorage.setItem("nickname", nickname);
      localStorage.setItem("userId", userId);

      navigate("/");

      if (!res.data.accessToken) {
        alert("토큰이 없습니다. 고객센터에 문의해주세요.");
        return;
      }
    } catch (error) {
      // 로그인 실패 시
      const message = error.response.data.message;
      alert(message || "로그인 중 문제가 발생했습니다."); // 사용자 확인
      console.log(message); // 개발자 확인
    }
  };

  return (
    <StContainer>
      <StTitle>로그인 창</StTitle>
      <StForm onSubmit={handleSubmit}>
        <StLabel htmlFor="id">아이디: </StLabel>
        <StInput
          type="text"
          id="id"
          ref={idRef}
          placeholder="아이디를 입력하세요."
        />
        <StLabel htmlFor="password">비밀번호</StLabel>
        <StInput
          type="password"
          id="password"
          ref={pwRef}
          placeholder="비밀번호를 입력하세요."
        />
        <StButton type="submit">로그인</StButton>
        <StButton
          type="button"
          onClick={() => {
            navigate("/sign-up");
          }}
          style={{ backgroundColor: "#6c757d", borderColor: "#6c757d" }}
        >
          회원가입하러 가기
        </StButton>
      </StForm>
    </StContainer>
  );
};

const StContainer = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 2rem;
  max-width: 500px;
  margin: auto;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  background-color: #f9f9f9;
`;

const StTitle = styled.h1`
  margin-bottom: 1.5rem;
  font-size: 2rem;
  color: #333;
`;

const StForm = styled.form`
  display: flex;
  flex-direction: column;
  width: 100%;
`;

const StLabel = styled.label`
  font-size: 1rem;
  margin-bottom: 0.5rem;
  color: #555;
`;

const StInput = styled.input`
  padding: 0.8rem;
  margin-bottom: 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
`;

const StButton = styled.button`
  padding: 0.8rem;
  margin: 0.5rem 0;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  color: #fff;
  background-color: #007bff;
  cursor: pointer;
  transition: background-color 0.3s ease;

  &:hover {
    background-color: #0056b3;
  }
`;

export default SignInPage;


4.3 PublicHomePage.jsx

요구사항

  • access 토큰이
    • 존재하는 경우 “안녕하세요 {닉네임}님!”
    • 존재하지 않는 경우 “로그인을 해주세요.”
import { Link } from "react-router-dom";
import styled from "styled-components";

const PublicHomePage = () => {
  const at = localStorage.getItem("accessToken");
  const nickname = localStorage.getItem("nickname");

  return (
    <StContainer>
      {at ? (
        <StGreeting>안녕하세요, {nickname}님!</StGreeting>
      ) : (
        <StGreeting>로그인을 해주세요!</StGreeting>
      )}
      {/* 로그인 여부가 상관없는 메뉴 */}
      <StList>
        <StListItem>
          <StLink to="/search">검색 페이지로</StLink>
        </StListItem>
        <StListItem>
          <StLink to="/test">권한테스트 페이지로</StLink>
        </StListItem>
      </StList>
      {/* 로그인이 반드시 필요한 메뉴 */}
      <StImportant>❗️ 로그인이 반드시 필요한 메뉴</StImportant>
      <StList>
        <StListItem>
          <StLink to="/user/1">1번 유저의 정보</StLink>
        </StListItem>
        <StListItem>
          <StLink to="/user/2">2번 유저의 정보</StLink>
        </StListItem>
      </StList>
    </StContainer>
  );
};

export default PublicHomePage;

const StContainer = styled.div`
  font-family: Arial, sans-serif;
  width: 100%;
`;

const StGreeting = styled.p`
  font-size: 1.2em;
  color: #333;
`;

const StList = styled.ul`
  list-style-type: none;
`;

const StListItem = styled.li`
  margin: 10px 0;
`;

const StImportant = styled.p`
  font-weight: bold;
  color: #e74c3c;
`;

const StLink = styled(Link)`
  text-decoration: none;
  color: #3498db;

  &:hover {
    text-decoration: underline;
  }
`;


4.4 SearchPage.jsx

요구사항

  1. useEffect를 활용하여 최초 렌더링 시 postsAxios를 활용하여 전체 데이터를 가져와 state에 세팅
  2. useSearchParams를 활용하여, searchParams에 따른 posts를 filtering 해서 보여주기
    1. ex ) http://localhost:3000/search?userId=2 인 경우 ⇒ 전체 post리스트 중, writerUserId가 2인 경우만 filtering 해서 출력
  3. searchParams는 아래 두 버튼을 통해 변경하도록 updateSearch() 함수를 완성하기

    <button onClick={() => updateSearch("1")}>1번유저의 글 보기</button>
    <button onClick={() => updateSearch("2")}>2번유저의 글 보기</button>
    


// src/pages/public/SearchPage.jsx
import postsAxios from "../../axios/posts";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";

const SearchPage = () => {
  const [searchParams, setSearchParams] = useSearchParams();
  const [posts, setPosts] = useState([]);

  const userId = searchParams.get("userId");

  // URL의 쿼리 스트링을 변경하는 함수
  const updateSearch = (userId) => {
    setSearchParams({ userId });
  };

  useEffect(() => {
    const getPosts = async () => {
      try {
        const res = await postsAxios.get("/");
        setPosts(res.data);
      } catch (error) {
        alert("글 목록을 가져오는 중 오류가 발생하였습니다.");
        console.log(error);
      }
    };
    getPosts();
  }, []);

  const filteredPosts = posts.filter(
    (post) => post.writeUserId === Number(userId)
  );

  return (
    <div>
      <h1>Posting 정보 보기(검색페이지)</h1>
      <div>
        {userId ? (
          <p>아이디 {userId}님이 쓰신 글</p>
        ) : (
          <p>아래 두 버튼 중 하나를 선택해주세요.</p>
        )}
      </div>
      <button onClick={() => updateSearch(1)}>1번 유저의 글 보기</button>
      <button onClick={() => updateSearch(2)}>2번 유저의 글 보기</button>

      {filteredPosts?.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.author}</p>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
};

export default SearchPage;


4.5 TestPage.jsx

요구사항

⚠️ 세팅한 custom axios로 인해, commentsAxios를 활용하여 값을 가져올 때는 localStorage에 accessToken이 없는 경우 실패해야한다. 별도 코딩은 필요 없으며, commentsAxios 내부에 해당 로직이 있으므로 테스트를 꼭 해보도록 하자.

  1. handleGetPostButtonClick : postsAxios를 이용하여 전체 포스팅을 가져온 후, post정보의 title과 author를 출력시키도록 하자. 해당 버튼이 클릭됐을 때는 이미 출력된 comments 리스트가 있다면 없애야 한다.
  2. handleGetCommentsButtonClick : commentsAxios를 활용하여 전체 댓글 리스트를 가져온 후, comment 정보의 body를 출력시키도록 하자. 해당 버튼이 클릭됐을 때는 이미 출력된 posts 리스트가 있다면 없애야 한다.


import commentsAxios from "../axios/comments";
import postsAxios from "../axios/posts";

const TestPage = () => {
  const [posts, setPosts] = React.useState([]);
  const [comments, setComments] = React.useState([]);

  const handleGetPostButtonClick = async () => {
    try {
      const response = await postsAxios.get("/");
      setPosts(response.data);
      setComments([]);
    } catch (error) {
      console.log(error);
      alert("포스팅을 가져오는 도중 에러가 발생하였습니다.");
    }
  };

  const handleGetCommentsButtonClick = async () => {
    try {
      const response = await commentsAxios.get("/");
      setComments(response.data);
      setPosts([]);
    } catch (error) {
      console.log(error);
      alert("accessToken 유효 시간이 만료되었습니다.");
    }
  };

  return (
    <div>
      <h1>Test Page</h1>
      <p>api 테스트를 진행합니다.</p>
      <button onClick={handleGetPostButtonClick}>
        posts가져오기 테스트(로그인필요없음)
      </button>
      <button onClick={handleGetCommentsButtonClick}>
        comments가져오기 테스트(로그인필요)
      </button>

      {posts?.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.author}</p>
        </div>
      ))}

      {comments?.map((comment) => (
        <div key={comment.id}>
          <p>{comment.body}</p>
        </div>
      ))}
    </div>
  );
};

export default TestPage;



5. Layout 작성

5.1 AuthLayout.jsx

요구사항

  • 로그인 시 localStorage에 저장했던 accessToken을 기준으로 로그인이 되어있지 않은 경우 alert로 안내한 후, 바로 login 페이지로 이동시키기
  • 💡 로컬스토리지는 useEffect에서 가져오지 않아도 된다! 굳이 비동기적으로 작성하지 않아도 컴포넌트가 렌더링되는 시점에 동기적으로 가져오기 때문이다.
import { Navigate, Outlet } from "react-router-dom";
import Navbar from "./Navbar";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const AuthLayout = () => {
  const isAuthenticated = localStorage.getItem("accessToken");

  if (!isAuthenticated) {
    alert("로그인이 필요한 페이지입니다. 로그인 페이지로 이동할게요!");
    // replace를 사용하여 이전 페이지로 돌아가는 것을 방지(새로운 히스토리로 덮어씌우기)
    return <Navigate to="/sign-in" replace />;
  }

  return (
    <>
      <Navbar />

      <StLayout>
        <StContainer>
          <StMessage>반드시 로그인이 되어있어야 하는 페이지입니다.</StMessage>
          <StMain>
            <Outlet />
          </StMain>
        </StContainer>
      </StLayout>
    </>
  );
};

export default AuthLayout;

const StLayout = styled.nav`
  background-color: #e2e2e2;
  border-bottom: 1px solid #ddd;
`;

const StContainer = styled(CommonContainer)`
  flex-direction: column;
  min-height: 100vh;
`;

const StMessage = styled.p`
  text-align: center;
  margin: 20px 0;
  font-size: 18px;
  color: #333;
`;

const StMain = styled.main`
  flex: 1;
  padding: 20px;
`;


❓useEffect를 사용해야 할까?

  • useEffect를 사용하여 로그인 여부를 판단할 경우, 로그인 여부를 판단하기 전에 사용자가 화면을 볼 수 있다는 문제가 발생할 수 있다.
  • 이는, useEffect는 컴포넌트가 렌더링된 이후에 실행되기 때문에, 로그인 여부를 판단하기 전에 컴포넌트의 초기 렌더링이 일어나기 때문이다.
  • 따라서 렌더링 여부를 판단하는 state를 생성해야 하거나, useEffect를 사용하지 않고 초기 렌더링 단계에서 직접적으로 로그인 여부를 판단하면 이러한 문제를 피할 수 있다.


5.2 NonAuthLayout

요구사항

  • 로그인 시 localStorage에 저장했던 accessToken이 존재하면, 해당 Layout에 접근할 수 없음을 의미하기 때문에 alert로 “이미 로그인 상태입니다” 안내한 후, 바로 Home 페이지(컴포넌트 : Home, 경로 : “/”)로 이동
import { Navigate, Outlet } from "react-router-dom";
import Navbar from "./Navbar";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const NonAuthLayout = () => {
  const isAuthenticated = localStorage.getItem("accessToken") ? true : false;

  if (isAuthenticated) {
    alert("이미 로그인 상태입니다.");
    return <Navigate to="/" replace />;
  }

  return (
    <>
      <Navbar />

      <StLayout>
        <StContainer>
          <StMessage>
            로그인이 반드시 안 되어 있어야 하는 페이지입니다.
          </StMessage>
          <StMain>
            <Outlet />
          </StMain>
        </StContainer>
      </StLayout>
    </>
  );
};

export default NonAuthLayout;

const StLayout = styled.nav`
  background-color: #e2e2e2;
  border-bottom: 1px solid #ddd;
`;

const StContainer = styled(CommonContainer)`
  flex-direction: column;
  min-height: 100vh;
`;

const StMessage = styled.p`
  text-align: center;
  margin: 20px 0;
  font-size: 18px;
  color: #333;
`;

const StMain = styled.main`
  flex: 1;
  padding: 20px;
`;


5.3 Navbar.jsx

요구사항

  • localStorage의 accessToken 여부에 따라 [로그인하러가기], [로그아웃] 버튼을 다르게 보여주기(조건부 렌더링)
  1. 로그인하러가기 버튼 클릭 시
    1. login 페이지로 라우팅
  2. 로그아웃 버튼 클릭 시
    1. localStorage에 저장되어있는 accessToken, userId, nickname을 제거
    2. Home 페이지(컴포넌트 : Home, 경로 : “/”)로 이동
  3. ul태그 내부의 li 태그를 활용하여 각 페이지에 대한 내비게이션을 완성시키기
    1. 1번 유저의 정보 : userId를 1로 params 지정할 수 있게 경로 설정
    2. 2번 유저의 정보 : userId를 2로 params 지정할 수 있게 경로 설정
// src/components/layouts/Navbar.jsx
import { NavLink, useNavigate } from "react-router-dom";
import styled from "styled-components";
import { CommonContainer } from "styles/CommonStyles";

const Navbar = () => {
  const at = localStorage.getItem("accessToken") ? true : false;
  const navigate = useNavigate();

  const handleLogoutButton = () => {
    const logoutConfirm = window.confirm("정말 로그아웃 하시겠습니까?");
    if (logoutConfirm) {
      localStorage.removeItem("accessToken");
      localStorage.removeItem("avatar");
      localStorage.removeItem("nickname");
      localStorage.removeItem("userId");
      navigate("/sign-in");
      alert("로그아웃 완료되었습니다!");
    }
  };

  return (
    <StNav>
      <StNavContainer>
        <StNavLink to="/">Home</StNavLink>
        {at ? (
          <StLogoutButton onClick={handleLogoutButton}>로그아웃</StLogoutButton>
        ) : (
          <StAuthLinks>
            <StNavLink to="/sign-in">로그인</StNavLink>
            <StNavLink to="/sign-up">회원가입</StNavLink>
          </StAuthLinks>
        )}
      </StNavContainer>
    </StNav>
  );
};

export default Navbar;

const StNav = styled.nav`
  background-color: #f8f9fa;
  padding: 10px 20px;

  border-bottom: 1px solid #ddd;
`;

const StNavContainer = styled(CommonContainer)`
  justify-content: space-between;
  align-items: center;
`;

const StNavLink = styled(NavLink)`
  color: #333;
  text-decoration: none;
  padding: 10px 15px;
  font-size: 16px;
  font-weight: 500;
  transition: color 0.3s ease, background-color 0.3s ease;

  &.active {
    font-weight: bold;
    color: #007bff;
    background-color: #e9ecef;
    border-radius: 5px;
  }

  &:hover {
    color: #0056b3;
  }
`;

const StLogoutButton = styled.div`
  cursor: pointer;
  color: #dc3545;
  font-size: 16px;
  font-weight: 500;
  padding: 10px 15px;
  border-radius: 5px;
  transition: background-color 0.3s ease;

  &:hover {
    background-color: #f8d7da;
  }
`;

const StAuthLinks = styled.div`
  display: flex;
  gap: 10px;
`;



7. 로그인 해야만 접근 가능한 페이지


태그:

카테고리:

업데이트:

댓글남기기