본문 바로가기

프로젝트/개인 프로젝트

230711~230714_Toy project_React를 이용한 게시판 페이지 제작

728x90

 

 


개요

 React 작업을 진행하면서 컴포넌트를 얼마나 더 세부적으로 쪼갤 수 있는지, 얼마나 재사용성을 높여서 작업할 수 있는지를 중점으로 작업을 진행하고자 했고, 아직 리엑트에서 axios를 설정하는 것이 어려워서 서버단이랑 연결하는 방법에 대해서도 터득하고자 하였다.

과제의 요구사항은 다음과 같다.

  • 게시판 페이지 제작
  • 글 추가, 삭제, 수정을 할 수 있다.
  • express를 사용해서 서버를 구축하고 게시판 CRUD 설정
  • 로그인을 JSONWEBTOKEN을 사용해서 로그인 인증 진행
  • 빌드 완료

이 요구사항들을 충족하되 가장 중요한 것은 아래의 내용이다.

" 내가 어디까지 알고 있는지, 어디까지 할 수 있는지 역량 체크를 위한 프로젝트이다. "

 

 

 

 


기획과정

 제작을 하기 앞서 먼저 구성을 해야했던 것은 컴포넌트 구조였다. 가장 왼쪽에 있는 'index.js'파일부터 순차적으로 자식 컴포넌트로 뻗어가는 구조로 제작하고자 하였다. 

 중복되는 컴포넌트를 최소화 하기 위해 같은 영역을 그려내는 컴포넌트는 재사용하고자 했다. 때문에 LoginSignup 컴포넌트는 회원가입 화면과 로그인 화면을 그리는 컴포넌트로 사용될 예정이고, PostCreate 컴포넌트는 게시글 추가 화면과, 수정화면을 그릴 컴포넌트로 사용될 예정이다.

 

DB는 게시글 값을 저장할 Post와 User로 테이블을 구성하였고, 자세한 내용은 아래와 같다.

 

 

 

 


제작과정

 경로는 프론트엔드와 백엔드 작업을 완전히 다른 폴더로 나눠서 작업을 진행하고, 프론트엔드의 작업은 'client'폴더에, 백엔드 작업은 'server'폴더에서 작업을 진행했다.

제작과정은 몇 가지 작업내용에 대한 코드나 중요한 내용의 코드를 위주로 소개하고자 한다.

 

 

💻 server / app.js_express 모듈을 사용하여 서버를 열어주고, 기본 설정을 진행한다.

 session, sequelize, cors, router 등의 설정을 진행한다. 이전에 nodejs 했을 때와 동일하게 진행하였는데, 클라이언트 단의 body에서 값을 받아올 때 값을 받아오지 못하는 에러가 발생하였다. 이 에러를 해결하기 위해 " app.use(express.json()); " 구문을 추가해주었다. 이 코드는 Express가 바디에서 받은 요청을 JSON으로 파싱하여 JS객체로 반환한다.

app.use(express.urlencoded({ extended: false }));

// json 객체로 넘겨받을 거임
app.use(express.json());

  

이 외의 백엔드 작업 내용들은 node때와 비슷하게 작업을 진행하였고, 특별한 내용이 없기 때문에 생략하고자 한다.

 

💻  client / redux / reducer / post.js & login.js_store에 저장할 상태 객체를 지정하고 리듀서 함수를 설정한다.

  • 게시글과 로그인에 관련한 정보를 각각의 파일에서 상태값을 설정하고 각 상태값에 맞게 리듀서 함수를 설정한다.
  • post.js의 reducer함수에서 전달받은 action의 값에 따라 반환되는 값을 넣어준다.
    • TITLEON : db에 등록된 게시글 정보를 모두 반환한다.
    • SELECT : db에 등록된 게시글 정보 중, 선택한 게시글에 대한 정보를 반환한다.

 

post.js

let init = {
  // 게시글의 제목
  title: "",
  // 게시글의 내용
  content: "",
  // 게시글 작성자
  writer: "",
  // 전체 게시글 정보
  list: "",
  // 선택한 게시글의 정보
  select: "",
};

function reducer(state = init, action) {
  const { type, payload } = action;

  switch (type) {
    case "TITLEON":
      return { ...state, list: payload.data };
    case "SELECT":
      return { ...state, select: payload };

    default:
      return { ...state };
  }
}

export default reducer;

 

💻  client / redux /store.js_store를 생성하고 applyMiddleware로 thunk를 미들웨어로 적용한다.

import { createStore, applyMiddleware } from "redux";
import reducer from "./reducer";
import thunk from "redux-thunk";

export const store = createStore(reducer, applyMiddleware(thunk));

 

💻  client / pages / Signup.jsx_회원가입 화면을 그리는 컴포넌트. 

  • isID와 isPW함수로 입력받은 아이디와 비밀번호가 정규식을 활용하여 조건에 맞는지 검사한다. input에 입력할 때마다, 입력한 값이 정규식에 내용과 부합하는지 확인시켜준다.
// 정규식의 내용과 비교하는 함수
const isID = (asValue) => {
    // 이메일 형식. @포함 여부와 대소문자 구분 없음
    const regID =
      /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;
    return regID.test(asValue);
  };

  const isPW = (asValue) => {
    // 최소 8자. 하나이상의 대소문자와 특수문자
    const regPW =
      /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
    return regPW.test(asValue);
  };



// 정규식 결과를 보여주는 함수
const Col = textCon ? "blue" : "red";

  const InputsID = () => {
    if (isID(id) == false) {
      // 아이디 조건 넣어 입력하세요 넣기
      setTextCon(false);
      setContentID("이메일 형식으로 기입해주세요!");
    } else {
      // 올바른 아이디입니다
      setTextCon(true);
      setContentID("올바른 형식의 아이디입니다.");
    }
  };
  const InputsPW = () => {
    if (isPW(pw) == false) {
      // 비밀번호 조건 넣어 입력하세요 넣기
      setTextCon(false);
      setContentPW("최소 8자.대소문자+특수문자!");
    } else{
      // 올바른 비밀번호 입니다.
      setTextCon(true);
      setContentPW("올바른 형식의 패스워드입니다.");
    }
  };
  
  
  // 화면을 그려주는 return 구문
  ....
  
          <UserInput
            onChange={(e) => {
              setId(e.target.value);
            }}
            onInput={InputsID}
            type="text"
            placeholder="아이디를 입력하세요."
          />
          <SignupResult style={{ color: Col }}>{contentID}</SignupResult>
          <UserInput
            onChange={(e) => {
              setPw(e.target.value);
            }}
            onInput={InputsPW}
            type="password"
            placeholder="패스워드를 입력하세요."
          />
          <SignupResult style={{ color: Col }}>{contentPW}</SignupResult>
  
  ....

 

  • 회원가입 버튼을 클릭하면 입력한 아이디와 패스워드가 정규식에 맞는지 확인 후, db에 입력한 정보를 저장해줄 Signupregex함수를 실행한다.
  // 정규식이 올바르면 해당 함수를 실행
  const signup = (id, pw, name) => {
    dispatch(Signupregex(id, pw, name));
  };

 

💻  client / components / middlewares / Signupregex.js_회원가입 시, 서버와 데이터를 주고 받는 컴포넌트

  • 매개변수로 받은 정보로 axios.post해 서버단으로 데이터를 보내준다. 서버에서 작업이 정상적으로 진행되고, db에 입력한 계정정보가 정상적으로 등록되면 message객체로 " 가입된 계정이 있습니다. "를 반환한다. 해당 정보가 반환되면 로그인을 진행할 수 있도록 루트 경로로 이동해준다.
  • Signupregex 함수는 Redux 액션 함수이기 때문에 반환하는 값이 객체가 아닌 함수여야 한다. 때문에 return으로 함수를 반환한다.
import React from "react";
import axios from "axios";

function Signupregex(id, pw, name) {
  console.log(id, pw, name);
  return (dispatch) => {
    axios
      .post(
        "http://127.0.0.1:8000/signup/create",
        { id, pw, name },
        { withCredentials: true }
      )
      .then((e) => {
          let result = e.data.message;
        if (result == "회원가입 완료") {
          window.location.href = "/";
        } else if (result == "가입된 계정이 있습니다.") {
          alert("회원가입 오류");
        }
      })
      .catch((err) => {
        console.log(err);
      });
  };
}
export default Signupregex;

    

💻  client / pages / Postlist.jsx_게시글 전체 목록(게시판 메인)을 그리는 컴포넌트. 

  • 컴포넌트가 처음 렌더링될 때, useEffect함수가 실행되어 PostListAction함수를 실행시켜 서버로부터 게시글을 가져와서 store에 저장시켜 사용한다. 해당 작업으로 페이지가 로드될 때 db에 저장된 게시글의 내용을 받아오는 것이다.
  • store에 저장된 내용을 useSelector로 선택한다. 만약 받아오는 값 이 undifined일 경우 빈 배열을 반환한다.
  • 게시글마다 수정을 할 수 있는 버튼이 있어, 각 게시글의 수정페이지로 넘어가려면 url에 고유값이 있도록  id값을 경로에 넣어 설정했다.
  • 게시글 삭제도 마찬가지로 특정 게시글의 데이터를 지우기 위해서 고유값인 id를 삭제하는 함수인 PostDelete의 매개변수로 전달했다.
import React, { useEffect } from "react";
import { PostDiv, PostBtn, APost, PostUpdateBtn } from "../components/layout";
import { useNavigate } from "react-router-dom";
import { PostListAction, PostDelete } from "../components/middlewares";
import { useDispatch, useSelector } from "react-redux";
const Postlist = () => {
  const nav = useNavigate();
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(PostListAction());
  }, []);

  const list = useSelector((state) => {
    // state.post.list가 존재하지 않거나 undifined일 경우 빈 배열을 반환
    return state.post.list || [];
  });

  const HandleClick = () => {
    return nav("/postAdd");
  };

  const UpdateClick = (id) => {
    // 선택한 게시글을 수정하는 함수
    return nav(`/postupdate/${id}`);
  };
  const DeleteClick = (id) => {
    // 선택한 게시글을 삭제하는 함수
    dispatch(PostDelete(id));
  };

  function views(list) {
    return list.map((el, index) => {
      return (
        <APost key={index}>
          <div className="contentDiv">
            <div className="writer">{el.writer}</div>
            <div className="contentBox">
              <div className="title">{el.title}</div>
              <div className="content">{el.text}</div>
            </div>
          </div>
          <div className="btns">
            <PostUpdateBtn
              onClick={() => {
                UpdateClick(el.id);
              }}
            >
              ✏️
            </PostUpdateBtn>
            <PostUpdateBtn
              onClick={() => {
                DeleteClick(el.id);
              }}
            >
              🗑️
            </PostUpdateBtn>
          </div>
        </APost>
      );
    });
  }

  return (
    <PostDiv>
      <PostBtn onClick={HandleClick}>글 추가</PostBtn>
      {views(list)}
    </PostDiv>
  );
};

export default Postlist;

 

💻  client / components / middlewares / PostListAction.js_회원가입 시, 서버와 데이터를 주고 받는 컴포넌트

  • axios.get으로 db에서 post테이블의 데이터를 받아와 dispatch를 사용하여 reducer에 "TITLEON"타입을 전달하여 액션을 실행시킨다.
    • PostListAction함수에서 payload로 게시글의 데이터로 reducer 함수에 전달하였고, reducer 함수에서 payload로 보낸 데이터를 store.list에 담는다.
import React from "react";
import axios from "axios";

const PostViewAction = (id) => {
  return (dispatch) => {
    axios
      .get(`/post/postupdate/${id}`, { withCredentials: true })
      .then((e) => {
        dispatch({ type: "SELECT", payload: e.data });
      })
      .catch((err) => {
        console.log(err);
      });
  };
};

export default PostViewAction;


// -------- reducer ------------------------------------
....
case "TITLEON":
	return { ...state, list: payload.data };
....

 

  • list로 반환받은 데이터를 map함수로 돌면서 render 시켜주었다.
// 게시글을 그려주는 함수
function views(list) {
    return list.map((el, index) => {
      return (
        <APost key={index}>
          <div className="contentDiv">
            <div className="writer">{el.writer}</div>
            <div className="contentBox">
              <div className="title">{el.title}</div>
              <div className="content">{el.text}</div>
            </div>
          </div>
          <div className="btns">
            <PostUpdateBtn
              onClick={() => {
                UpdateClick(el.id);
              }}
            >
              ✏️
            </PostUpdateBtn>
            <PostUpdateBtn
              onClick={() => {
                DeleteClick(el.id);
              }}
            >
              🗑️
            </PostUpdateBtn>
          </div>
        </APost>
      );
    });
  }



// 게시글 리스트를 render
  return (
    <PostDiv>
      <PostBtn onClick={HandleClick}>글 추가</PostBtn>
      {views(list)}
    </PostDiv>
  );

 

💻  client / pages / PostUpdate.jsx_게시글 수정 화면을 그리는 컴포넌트

  •  게시글 수정을 진행하면 state에 저장되어 있는 값으로 db에 데이터를 보내준다. 이전처럼 " const [title, setTitle] = useState(""); "로 진행하면, 제목이나 본문에서 수정을 진행하지 않으면 useState의 값이 비어있기 때문에 값이 없는 상태로 db에 보내질 것이다. 그렇기 때문에, 기존에 등록되어 있던 글의 정보를 받아오는 post객체의 정보를 담아주어, 수정작업이 진행되지 않으면 이전에 등록된 정보를 그대로 가져갈 수 있도록 한다.
// 선택한 게시글의 데이터를 가져온다.
const post = useSelector((state) => {
    console.log("#######", state.post.select);
    return state.post.select;
  });
  
// state 값을 수정하지 이전의 값으로 설정한다.
  const [title, setTitle] = useState(post.title);
  const [content, setContent] = useState(post.text);
  
// defaultValue로 input에 기존에 입력되어 있던 데이터를 그려준다.
return (
    <PostAddDiv>
      <div className="Title">
        <div>Title</div>
        <input
          onChange={(e) => {
            setTitle(e.target.value);
          }}
          defaultValue={post.title}
        ></input>
      </div>
      <div className="Content">
        <input
          onChange={(e) => {
            setContent(e.target.value);
          }}
          defaultValue={post.text}
        ></input>
      </div>
      <div>
        <PostBtn onClick={HandleClick}>수정</PostBtn>
      </div>
    </PostAddDiv>
  );

 

 

🤔 재사용된 컴포넌트

userDiv / userInput 

  • userDiv
    • 로그인 화면, 회원가입 화면 중앙영역의 화면 구성을 설정
  • userInput
    • 로그인 화면, 회원가입 화면의 input 스타일을 설정

 

PostAddDiv / PostBtn

  • postAddDiv
    • 게시글 생성 화면과 게시글 수정 화면 중앙 영역의 화면 구성을 설정
  • PostBtn
    • 게시글 생성, 수정 페이지의 button 스타일을 결정

 

 

 

 


이슈사항

❗️ styled 적용 시, 전체 영역에 대한 스타일 적용방법

이슈사항이라고 말하긴 어렵고 사실 작업하면서 새롭게 알게 된 부분이다.

 작업을 진행할 당시만 해도, ' styled-components ' 에서 " styled "를 사용하여, 사용하고 싶은 컴포넌트태그를 설정하고 해당 태그의 스타일을 설정하는 방법만을 배웠었는데, 페이지에 웹 폰트를 적용하고 싶어 생각을 해보니, 웹 폰트의 설정내용은 전역 영역에 기입을 해야하는 상황이라, 전역으로 코드를 설정할 수 있는 방법을 알아봤다.

 ' createGlobalstyle ' 을 사용하면 전역의 영역에서 스타일에 관련한 코드를 사용할 수 있다는 사실을 알 수 있었다.  글로벌 영역에 설정할 스타일. 예를 들어 웹폰트, 미디어, 키프레임 등과 같은 설정에 대한 코드들을 작업할 수 있는 영역이라고 생각했다.

 

아래의 코드는 createGlobalstyle을 사용하여 스타일 코드를 작성한 내용이다.

import { createGlobalStyle } from "styled-components";
// createGlobalStyle : 글로벌 스타일을 생성한다.
export const Globalstyle = createGlobalStyle`
@font-face {
    font-family: 'OAGothic-ExtraBold';
    src: url('https://cdn.jsdelivr.net/...');
    font-weight: 800;
    font-style: normal;
}

@font-face {
    font-family: 'SUITE-Regular';
    src: url('https://cdn.jsdelivr.net/...');
    font-weight: 400;
    font-style: normal;
}


& .App{
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100vw;
    height: 100vh;
}
`;

 

createGlobalstyle페이지에서도 렌더될 수 있도록 하려면, 스타일이 적용될 컴포넌트보다 먼저 선언이 되어 있어야 한다. 때문에, 모든 페이지에 노출이 될 Header 컴포넌트 영역에 Globalstyle 컴포넌트를 가장 부모영역에 선언하여 모든 페이지에서 적용될 수 있도록했다.

import React from "react";
import { HeaderBox, Globalstyle } from "../components/layout";

const Header = () => {
  return (
    <>
      {/* Globalstyle가 전역부분의 스타일 값을 가지고 있으므로, 스타일이 적용되는 컴포넌트보다 먼저 선언되어야 한다. */}
      <Globalstyle />
      <HeaderBox>
        <p>POST</p>
      </HeaderBox>
    </>
  );
};

export default Header;

 

 

❗️로그인을 해도 서버단에서 jwt로 설정한 쿠키 값이 브라우저에서 확인되지 않던 이슈

 

 지난 nodejs를 배울 때 썼던 방법대로, 로그인을 완료하면 jsonwebtoken 모듈을 사용하여 유저의 정보와 accessToken으로 Token을 생성해, 로그인을 유지시키는 시간을 설정했었다. 해당 토큰이 생성되면 브라우저의 개발자모드 쿠키에서 생성된 token의 정보를 확인할 수 있어야 하는데, 로그인 시 생성되는 Token값은 콘솔에서 확인이 되지만, 아무리 로그인을 진행해도 쿠키가 확인되지 않는 이슈가 발생하였다. cors 설정도 했는데 어떤 부분이 문제인지 알 수 없으니 답답한 상태였다.

우선 해당 이슈를 해결하지 못한채로 작업을 진행하다가 천사같은 친구가 해결방법을 알려줬다☺️ 방법은 아래와 같다.

✎ 클라이언트 단의 pakage.json 파일에서 "proxy":"http://127.0.0.1:8000" 을 추가한다.

               • 8000번은 express로 지정한 서버의 포트번호이다.

✎ axios의 경로가 기존에는 'http://127.0.0.1:8000/signup/login'로 설정되어 있었다면 /signup/login 으로 수정한다.

// 클라이언트 단의 pakage.json
{
  "name": "post",
  "version": "0.1.0",
  "private": true,
  "proxy": "http://127.0.0.1:8000",
  
  
  
// axios 사용 예시
import React from "react";
import axios from "axios";

const PostListAction = () => {
  return (dispatch) => {
    axios
      .get("/post/postlist", {
        withCredentials: true,
      })
      .then((data) => {
.....

 쿠키값이 왜 적용되지 않았고, 해당 방법으로 적용하니 제대로 동작했던 이유를 알고 싶어 확인해보니, 브라우저는 보안 상의 이유로 서로 다른 도메인, 포트, 프로토콜 간의 리소스 요청을 제한하기 때문에 적용되지 않았던 것이라고 한다.  이 부분에 대해서는 cors를 배울 때 알게된 부분이었으니 이해를 했는데 그럼 지금은 어떤 문제가 있었는지 확인하니 React 개발 서버는 일반적으로 3000번 포트를 사용하고, 서버에서 작업으로 사용할 포트번호를 8000번으로 설정했으니, 서로 다른 포트번호이기 때문에 쿠키 값을 주고 받을 수 없었을 것이라고 생각한다.

 해당 문제를 pakage.json에서 proxy 설정을 하면 React 개발 서버의 API 요청을 포록시에서 설정한 경로로 전달할 수 있게되는 것이다. 따라서 API 요청 시, ' /signup/createUser '로 경로 설정을 하면, 프록시의 ' http://127.0.0.1:8000 '로 설정된 경로를 따라 최종적으로 ' http://127.0.0.1:8000/signup/createUser '로 프록시를 통해 전달 할 수 있게되는 것이다.

 하지만 해당 방법은 개발 시에만 사용할 수 있으며, 실제 배포 환경에서는 적용되지 않는 방법이라고 한다. 배포환경에서 적용을 하기 위해서는 Reverse Proxy나 도메인 병합, CORS 등의 방법으로 설정할 수 있다고 한다.

 

 

❗️실행할 PORT가 이미 실행중이라 npm start가 실행되지 않던 이슈

 학원에서 npm 문제 없이 작업을 진행하다가 집에 와서 마저 작업을 진행하려고 보니 갑자기 npm start에서 에러가 발생했다. 여지껏 수 많은 npm start를 실행했었지만 난생 처음보는 에러 앞에서 당황스러워졌었는데 에러 내용을 보니 ' Something is already running on port 3000. ... Would you like to run the app on another port instead? › (Y/n) ' 이라고 한다. 이미 3000번 포트를 사용하고 있다는 말이었다. 

 

1. 

해당 부분에서 여러 해결방법이 있었는데, 아마도 가장 쉬운 방법은 에러메세지에서 제안한대로 다른 포트를 사용하겠냐는 제안에 'y'를 입력했으면 3001번 포트로 설정이 바뀌어 살행이 가능할 것이다.  하지만 나는 내가 정확히 어떤 포트번호로 작업을 할지 설정을 하고 싶었으므로 해당 방법을 사용하진 않았다.

 

2.

 두 번째 방법으로는 해당 포트번호에 이미 실행중인 프로세스를 종료시켜 포트를 해제한 후 내 작업을 실행시키는 방법이었다. 이 방법은 프로세스 ID를 사용하여 해당 아이디의 프로세스를 강제 종료 시킬 수 있어 터미널에서( PID : 5590 ) ' kill 5590 '을 입력하면 된다. 하지만 나는 이 방법으로는 해결되지 않았었다. 

 

3. 

 세 번째 방법은 지금 이었다.  방법으로 찾아보니 여러 방법이 있는 듯 했지만, 나는 reacte의 pakage.json 에서 start 명령문을 React에서 사용하려는 포트 번호를 다른 포트번호로 변경을 하는 방법 "PORT=4000 react-scripts start" 로 수정하여 4000번 포트에서 열릴 수 있도록 경로를 변경해주었다.

 

나는 3번 방법으로 코드를 수정하였고, 문제 없이 npm start를 실행할 수 있었다.

// React의 pakage.json
{
 .....
  "proxy": "http://127.0.0.1:8000",
  "dependencies": {
   .....
  },
  "scripts": {
    "start": "PORT=4000 react-scripts start",
.....

 

 

 

 


결과물

📽️ 회원가입

 

📽️ 로그인

 

📽️ 게시글 등록

 

📽️ 게시글 수정, 삭제

 

 

 

 

728x90