블로그

김동현·2023년 7월 17일
0

개인 프로젝트

목록 보기
12/13
post-thumbnail

목적

프론트엔드와 백엔드를 분리시켜 개발하고 api로 네트워크 통신을 하는 프로젝트를 만들어 보고 싶었다.

프론트엔드는 리액트와 리액트 라우터로 개발하였고 백엔드는 express로 개발하였다.

로그인 기능은 이번에 공부한 jwt를 적용해보았다.

사용한 기술

  • react
  • react router
  • react quill
  • axios
  • express
  • bcrypt
  • jsonwebtoken
  • mysql

프로젝트 설명

백 엔드

routes

3개의 라우터를 이용한다.

app.use("/api/auth", authRouter);
app.use("/api/users", usersRouter);
app.use("/api/posts", postsRouter);

app.use((err, req, res, next) => {
  console.error(err);
  return res.sendStatus(500); // 서버 오류;
});
  • authRouter: 로그인 및 로그아웃과 같이 사용자의 활동에 관한 라우터
  • usersRouter: 유저의 회원가입, 탈퇴와 같이 유저 데이터를 다루는 라우터
  • postsRouter: 게시글의 생성, 수정, 삭제와 같이 게시글 데이터를 다루는 라우터

authRouter

router.post("/login", loginController);
router.get("/refreshtoken", refreshtokenController);
router.get("/logout", jwtVerify([ROLES.USER, ROLES.ADMIN, ROLES.EDITOR]), logoutController);

로그아웃은 로그인이 된 상태에서만 컨트롤러로 접근이 가능하도록 중간에 로그인 인증 미들웨어를 넣었다.
refreshtoken와 role를 사용하는 jwt 인증방식이다.

usersRouter

router.post("/", registerController);
router.post("/:username", jwtVerify([ROLES.ADMIN, ROLES.USER]), checkUserController);
router.delete("/:username", jwtVerify([ROLES.ADMIN, ROLES.USER]), deleteController);

rest api 형식을 이용했다.
그런데 중간에 rest api와는 어울리지 않는 라우트가 있다.

router.post("/:username", jwtVerify([ROLES.ADMIN, ROLES.USER]), checkUserController);

컨트롤러의 이름이 checkUserController 이다.
유저의 정보를 체크하는 라우트이다.
보통 회원 탈퇴를 위해서는 아이디와 비밀번호를 한 번 더 입력하게 되는데
그를 위한 라우트이다.
delete 요청에는 body를 포함시킬 수 없고, 그렇다고 get요청으로 아이디와 비밀번호를 전달할순 없기에 만들었다.
프론트 단에서 이 요청이 성공적으로 이루어지면 delete요청을 한다.

postsRouter

router.get("/", getPosts);
router.get("/:id", getPost);
router.post("/", jwtVerify([ROLES.USER, ROLES.EDITOR, ROLES.ADMIN]), addPost);
router.delete("/:id", jwtVerify([ROLES.USER, ROLES.EDITOR, ROLES.ADMIN]), deletePosts);
router.patch("/:id", jwtVerify([ROLES.USER, ROLES.EDITOR, ROLES.ADMIN]), updatePosts);

rest api를 적용했다.
게시글의 생성, 수정, 삭제에는 로그인 권한이 필요하므로 인증 미들웨어를 추가했다.

프론트 엔드

프론트 단은 리액트로 만들었다.
페이지 라우터는 리액트 라우터를 이용했다.

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <Home />, loader: homeLoader },
      {
        path: "/posts/:id",
        element: <Post />,
        loader: postLoader,
      },
      {
        path: "/write",
        element: (
          <RequireAuth>
            <Write />
          </RequireAuth>
        ),
      },
    ],
  },
  {
    path: "/login",
    element: <Login />,
  },
  {
    path: "/join",
    element: <Register />,
  },
  {
    path: "/withdraw",
    element: (
      <RequireAuth>
        <UserDelete />
      </RequireAuth>
    ),
  },
]);

Home 페이지

특별한 점은 리액트 라우터의 loader 함수를 이용해서 서버사이드 랜더링과 같은 효과를 줘보았다.
즉, 데이터가 로드되기 전까지 화면이 랜더링 되지 않는다.

Post 페이지

마찬가지로 loader 함수를 사용해서 서버사이드 랜더링과 같은 효과를 줬다.
왼쪽의 메인 포스터에 대한 데이터를 얻는 요청을 한 뒤에 메인 포스터와 같은 카테고리를 갖는 포스터들을 얻는 요청을 한다.
즉, 두 번의 요청을 한다.
관련 포스터들은 최신 등록날짜 순으로 8개를 가져온다.

Write 페이지

이 페이지는 회원 전용 페이지이다.
우측 상단에 보면 myname 이라는 아이디가 보인다.
만약 로그인을 하지 않은채 /write 주소에 접근하면 /login 페이지로 리다이렉트 된다.
이미지를 직접 디비에 넣기에는 용량부담이 있어서 이미지 url로 대체했다.

react-quill 라이브러리를 사용해서 content 박스를 구현했다.
특이한 점으로는 content 박스가 <textarea> 를 사용해서 구현된줄 알았는데 일반 <p> 엘리먼트로 구현되어 있었다.
따라서 form 제출시에 content 박스의 value값이 formdata에 자동으로 추가가 되지 않았다.
그래서 다음과 같이 핸들러를 따로 만들어 주었다.

const [inputs, setInputs] = useState(
    { imageURL: "", title: "", sanitizedContent: "", rowContent: "", category: "art" }
  );

const changeHandler = (e) => {
    setInputs({ ...inputs, [e.currentTarget.name]: e.currentTarget.value });
  };
const quillChangeHandler = (content, delta, source, editor) => {
  setInputs({ ...inputs, rowContent: content, sanitizedContent: editor.getText() });
};

이 페이지는 게시글 수정 페이지로도 재사용되었다.
작성자에 한해서 포스터 수정 및 삭제 버튼이 생기는데 수정 버튼을 누르면 해당 포스터의 데이터를 state에 담아 write페이지로 이동시킨다.

// post페이지
<button className="post__target__btn post__target__btn_edit" onClick={() => navigate("/write", { state: post })}>...</button>

write 페이지에서는 state에 담긴 데이터를 location 객체를 통해서 가져올 수 있다.
리액트 라우터는 useLocation 이라는 훅을 제공하므로 이 훅으로 location 객체를 가져왔다.

const post = useLocation().state;
  const { getMyAxiosInstance } = useContext(AuthContext);
  const [inputs, setInputs] = useState(
    post?.id ? { imageURL: post.imageURL, title: post.title, sanitizedContent: post.sanitizedContent, rowContent: post.rowContent, category: post.category } : { imageURL: "", title: "", sanitizedContent: "", rowContent: "", category: "art" }
  );

만약 포스터 작성 화면이라면 input 박스들이 빈칸으로 나타날 것이고 수정 화면이라면 기존의 post 데이터들이 채워진 input 박스들이 나타날 것이다.

회원가입 페이지

로그인 페이지

회원 탈퇴 페이지

이 페이지에서는 id 박스를 value값을 로그인한 본인의 id로 초기화하고 disabled 를 적용했다.
또한 위에서 언급한 대로 해당 유저를 디비에서 찾아 확인한 후에 삭제요청을 하도록 만들었다.

try {
  await checkUser(inputs.username, inputs.password, getMyAxiosInstance());
  await withdraw(inputs.username, getMyAxiosInstance());
  await logout(false);
} catch (err) {
  setError(err.response.data.message);
}

logout(false) 는 서버로 요청 없이 클라이언트 단에서의 로그인된 유저 정보를 삭제시키는 함수이다.
기본값은 true이며 이 경우 서버로 요청하여 서버와 클라이언트 둘 다 로그인 정보를 삭제시킨다.

context API

로그인된 유저 정보는 프로젝트 내에서 공유되는 정보이므로 context api를 통해 관리했다.

import { createContext, useState } from "react";
import axios from "axios";
export const AuthContext = createContext();

const AuthProvider = ({ children }) => {
  const [user, setUser] = useState();
  const [accessToken, setAccessToken] = useState();

  const login = async (inputUsername, inputPassword) => {
    try {
      const response = await axios.post("/auth/login", { username: inputUsername, password: inputPassword });
      const { username, email, role } = JSON.parse(atob(response.data.accessToken.split(".")[1]));
      setAccessToken(response.data.accessToken);
      setUser({ username, email, role });
    } catch (err) {
      throw err;
    }
  };
  const getRefreshToken = async () => {
    try {
      const response = await axios.get("/auth/refreshtoken");
      setAccessToken(response.data.accessToken);
      return response.data.accessToken;
    } catch (err) {
      throw err;
    }
  };

  const logout = async (isRequest = true) => {
    try {
      if (isRequest) await getMyAxiosInstance().get("/auth/logout");
      setAccessToken(null);
      setUser(null);
    } catch (err) {
      throw err;
    }
  };
  const getMyAxiosInstance = () => {
    const instance = axios.create({
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    instance.interceptors.response.use(
      (response) => response,
      async (error) => {
        const prevRequest = error?.config;
        if (error?.response?.status === 403) {
          const newAccessToken = await getRefreshToken();
          prevRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
          return instance(prevRequest);
        }
        return Promise.reject(error);
      }
    );
    return instance;
  };

  return <AuthContext.Provider value={{ user, login, logout, getMyAxiosInstance }}>{children}</AuthContext.Provider>;
};

export default AuthProvider;

setUser 함수를 공유하지 않고 user 데이터 외에 login , logout , getMyAxiosInstance 함수만을 공유하여 user 데이터의 조작을 제한했다.
getMyAxiosInstance 는 유저의 accesstoken을 설정하는 axios 인스턴스를 생성하는 함수이다.
따라서 accesstoken 또한 외부로부터 숨길수 있다.

getMyAxiosInstance 함수 내에 보면 interceptors.response.use() 메서드가 사용된 것을 볼 수 있다.
이는 accesstoken의 사용기한이 만료가 되면 자동으로 refreshtoken을 기반으로 새로운 accestoken을 받아오도록 하는 동작을 추가한 것이다.

새로 배운 부분

react-quill

react-quill은 quill을 기반으로 리액트에서 사용하기 쉽게 컴포넌트화한 라이브러리이다.
게시글 입력 박스를 어떻게 구현하는지 너무 막연했었는데... 역시 귀찮고 어려운 작업들은 이미 누구가가 만들어놓았다는 사실을 다시금 깨닫는 시간이었다.


axios

axios 라이브러리의 interceptors객체의 사용법을 알게되었다.
이전에 axios 라이브러리를 사용할 때는 fetch 함수와 다를바 없이 사용했다.
그래서 axios의 필요성을 느끼지 못했었다.
fetch의 res.json() 를 생략할수 있다는 느낌(?) 정도로 생각했었다.
하지만, 이 프로젝트를 통해서 네트워크 통신을 인스턴스화하고 통신 중간에 조작을 가하는 기능들을 사용함으로써 axios 라이브러리의 위대함(?)을 깨달았다.
위에서는 응답을 가로채서 통신 중간에 조작을 가했지만 요청 작업도 중간에 가로챌 수 있다.

// Add a request interceptor
axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    return config;
  }, function (error) {
    // Do something with request error
    return Promise.reject(error);
  });

// Add a response interceptor
axios.interceptors.response.use(function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  }, function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  });

요청 같은 경우엔 요청 전에 config를 수정하여 요청할 수 있도록 한다.
프로젝트에서는 axios 인스턴스를 만들 때 accesstoken을 바로 세팅되도록 했지만 config를 수정해서 세팅해도 된다.


리액트 라우터의 action 함수

리액트 라우터에서 제공하는 action 함수를 사용해보았지만 중간에 삭제했다.
상황에 적절하지 못하다고 생각했기 때문이다.
리액트 라우터의 action은 <Form> 엘리먼트의 action과 매칭이 되는 함수이다.
그런데 <Form> 엘리먼트는 결국 <form> 과 비슷하게 동작하지만 결국은 가짜 form이다.
외부요청을 하지 않기 때문이다.
하지만 나는 백엔드로의 api요청을 해야했다.
외부요청을 하지 않는 <Form> 의 action에서 외부요청을 하니 코드가 복잡해질 뿐더러 형식에 맞지 않는 상황이 발생했다.
예를들어 action 함수 내에서는 리액트 훅을 사용할 수가 없었다.
훅을 사용할 수 없으니 외부요청을 위한 훅도 사용할 수가 없고 구조적인 문제가 발생되었다.

느낀점

클라이언트단에서 서버단까지 연결을 시켜서 웹 개발이라는 하나의 사이클을 만들어 보았다.
전반적으로 중구난방하게 흩어져있던 지식들을 정리정돈한 느낌이다.
다만, 프로젝트를 진행하면서 주먹구구식으로 개발을 진행하다보니 코드 정리에 시간이 꽤 걸렸었다.
현재 타입스크립트를 공부중에 있는데 타입스크립트를 활용해서 개발하면 함수 타입을 문서화 할 수 있기 때문에 어느정도 해결될 것으로 보인다.

아쉬운 점으로는 이 프로젝트 역시 배포를 하고 싶었으나 mysql때문에 배포를 포기했다.
클라우드 db를 사용하기위해선 돈을 지불해야 하기 때문이다.
공짜 배포 사이트를 찾아서 배포를 시도했으나 여러모로 기간유지가 상당히 짧았기에 배포는 포기했다.

  • ...인줄 알았는데 AWS로 배포가 가능하다. 단, 기간제로...
    그런데 기간이 넉넉하므로 취업전까지는 배포가 될 듯하다.
    AWS에 배포하면서 처음 AWS를 써봤는데 사람들이 왜 AWS를 쓰는지 이제야 알것같다.

MySQL은 AWS RDS서비스를 이용했고 백엔드 서버는 AWS EC2를 이용했다.
EC2는 리눅스 22버전이며 node.js를 설치해서 사용했다.
client는 빌드한 결과물을 백엔드에 붙여서 실행했기때문에 client측 패키지들은 설치하지 않아도 된다.
메모리 아껴야하니까...돈든다....

결과물

profile
프론트에_가까운_풀스택_개발자

0개의 댓글