드디어 약 2달동안 진행했던 '구르미 월드' 개발을 끝마쳤다 .. !
계획은 약 1달 반 동안 진행하려고 했지만 개발을 하면서 점점 욕심이 생기고 기능들을 추가하고 수정하느라 시간이 좀 더 걸렸던 것 같다. 물론 프로젝트에만 매진한 것도 아니기도 했고 다른 것도 하면서 진행하느라 좀 더 늦춰진 거 같긴하다 .
사실 취업준비를 하면서 지금 새 프로젝트를 시작하는게 맞을까 ? 하는 고민도 있었지만 하길 잘했다 느꼈다.
리액트를 주로 사용하긴 하는데 시간이 지나면 기본적인 것도 헷갈리고 헤맸던 부분도 있었는데 이번 프로젝트를 통해 리액트에 대한 기반을 다질 수 있었고 백엔드와 원할하게 소통하는 방법을 익힐 수 있었다.
바로 전 프로젝트에서는 백엔드 분들과 협업할 때 백엔드 분들이 사용하시는 용어들을 이해하지 못해서 다시 물어봐서 이해하곤 했지만 이번 프로젝트에서는 백엔드 1명과 둘이서 진행한 프로젝트인 만큼 세세한 부분까지 소통할 수 있어서 많이 배워가고 도움이 된 프로젝트였다.
프로젝트 깃허브 주소
https://github.com/baejb/cloudworld
-> 프로젝트 리드미에 프로젝트에 대한 자세한 설명을 작성해두었다
프로젝트 구조
📦src
┣ 📜public
┣ 📂img
┃ ┣ 📜cloudyblue1.png
┃ ┣ 📜cloudyblue2.png
┃ ┣ 📜cloudyblue3.png
┃ ┣ 📜cloudygreen1.png
┃ ┣ 📜cloudygreen2.png
┃ ┣ 📜cloudygreen3.png
┃ ┣ 📜cloudypurple1.png
┃ ┣ 📜cloudypurple2.png
┃ ┣ 📜cloudypurple3.png
┃ ┣ 📜cloudyyellow1.png
┃ ┣ 📜cloudyyellow2.png
┃ ┣ 📜cloudyyellow3.png
┃ ┣ 📜landing.png
┃ ┗ 📜landinglong.png
┣ 📜cloudlogo.ico
┣ 📂components
┃ ┣ 📂Home
┃ ┃ ┣ 📜Board.jsx
┃ ┃ ┣ 📜BoardList.jsx
┃ ┃ ┣ 📜Cloudy.jsx
┃ ┃ ┣ 📜Pagination.jsx
┃ ┃ ┗ 📜Profile.jsx
┃ ┣ 📂Login
┃ ┃ ┣ 📜GoogleLoginLiv.jsx
┃ ┃ ┣ 📜KaKaoLogin.jsx
┃ ┃ ┣ 📜LogOut.jsx
┃ ┃ ┣ 📜Oauth.jsx
┃ ┃ ┗ 📜SetToken.js
┃ ┗ 📜Footer.jsx
┣ 📂pages
┃ ┣ 📜BookmarkPage.jsx
┃ ┣ 📜EditPage.jsx
┃ ┣ 📜ErrorPage.jsx
┃ ┣ 📜HomePage.jsx
┃ ┣ 📜LandingPage.jsx
┃ ┣ 📜LoginPage.jsx
┃ ┣ 📜SettingPage.jsx
┃ ┗ 📜UpgradePage.jsx
┣ 📂states
┃ ┗ 📜LoginAtoms.jsx
┣ 📜App.css
┣ 📜App.jsx
┣ 📜constants.js
┣ 📜index.css
┗ 📜main.jsx
프로젝트에 대한 자세한 설명은 깃허브 리드미에 작성해두었으니 따로 블로그에는 작성하지 않고,
프로젝트를 진행하면서 내가 처음 만들어본 기능들과 새롭게 사용해본 기술 그리고 아쉬웠던 부분에 대해 작성하고 전체적으로 프로젝트를 진행하며 느낀점을 서술할 것이다.
🛠️ 처음 만들어본 기능 & 새롭게 사용해본 기술
1. 소셜 로그인 및 프론트에서 jwt 다루기
회원가입/ 로그인 파트를 개발해본 적이 없어 이번에 처음으로 유저 관련 개발을 해보게 되었다. 로그인은 프로젝트 특성 상 자체로그인으로 진행하면 프로젝트를 이용하는 사람들이 적어질 것 같아서 소셜 로그인으로만 구현하기로 했다. (소셜 로그인으로는 카카오와 구글을 적용했다.)
프론트에서 소셜로그인을 하는데 큰 어려움은 없었다. 카카오 개발 문서와 몇개의 블로그를 참고하여 카카오 소셜 로그인 코드를 작성할 수 있었다.
KaKaoLogin.jsx
import styled from 'styled-components';
import { RiKakaoTalkFill } from "react-icons/ri";
import { domain } from '../../constants';
const KaKaoBtn = styled.button`
width: 220px;
height: 50px;
background-color: #FFEB02;
display: flex;
justify-content: space-evenly;
align-items: center;
border-radius: 20px;
border: none;
font-size: 14px;
margin:5%;
>div{
width:150px;
}
&:hover {
cursor: pointer;
}
&:active{
transform: translate(0px ,3px);
}
backdrop-filter: blur(5px);
background-color: #FFEB02;
box-shadow: inset -8px -8px 16px 0px rgb(255, 235, 19), inset 0px 11px 28px 0px rgb(255, 255, 255);
`
const KaKaoLogin = () => {
const REST_API_KEY = '보안상 가려두겠습니다.';
const REDIRECT_URI = `${domain}oauth`;
// const REDIRECT_URI = 'http://localhost:5173/oauth';
const link = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;
const loginHandler = () => {
window.location.href = link;
};
return (
<KaKaoBtn type='button' onClick={loginHandler}>
<RiKakaoTalkFill/>
<div>
kakao 로그인 하러가기
</div>
</KaKaoBtn>
);
};
export default KaKaoLogin;
Oauth.jsx
import React from 'react';
import axios from 'axios';
import { useRecoilState } from 'recoil';
import { isLoggedInState, userIdState } from '../../states/LoginAtoms';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { baseUrl } from '../../constants';
import SetToken from './SetToken';
const Oauth = () => {
const [isLoggedIn, setIsLoggedIn] = useRecoilState(isLoggedInState);
const [userId, setuserId] = useRecoilState(userIdState);
const navigate = useNavigate();
useEffect(() => {
const fetchData = async () => {
try {
const code = new URL(window.location.href).searchParams.get("code");
if (code) {
const response = await axios.post(
`${baseUrl}/oauth`,
code ,
{
withCredentials: true,
headers: {
"ngrok-skip-browser-warning": true,
},
}
);
const rtk = response.data.token[0].token;
const atk = response.data.token[1].token;
const rtkExpiredTime = response.data.token[0].tokenExpiresTime;
const atkExpiredTime = response.data.token[1].tokenExpiresTime;
const userId = response.data.userId;
localStorage.setItem('rtk', rtk); //리프레쉬 토큰
localStorage.setItem('token', atk); //액세스 토큰
localStorage.setItem('rtkTime', rtkExpiredTime ); //리프레쉬 토큰 만료기간
localStorage.setItem('atkTime', atkExpiredTime); // 액세스 토큰 만료기간
localStorage.setItem('userId', userId );
const userState = response.data.status ;
// // Recoil 상태 업데이트
setuserId(userId);
if(userState === 201){
setIsLoggedIn(true);
navigate(`/setting/${userId}`);
} else if(userState === 200){
setIsLoggedIn(true);
navigate(`/home/${userId}`)
}
}
} catch (error) {
console.error('Error:', error);
}
};
SetToken();
fetchData();
}, []);
return(
<>
<div> 로그인 처리 중 입니다. </div>
</>
)
};
export default Oauth;
프론트에서 할 일은 앱 키로 등록한 후 발급받은 rest_api_key 와 redirect_uri 를 선언하고 로그인 클릭시 파라미터 값으로 rest_api_key 와 redirect_uri를 전달해 카카오 로그인 창으로 이동하여 사용자가 로그인 후 받은 code 값을 백엔드로 전달하기만 하면 됐다.
로그인을 구현하면서 어려웠던 점은 액세스 토큰과 리프레쉬 토큰 관련이었는데 개념도 낯설기도 하고 코드를 어떻게 짜야할지 감이 안왔던 것 같다. 따라서 여러 블로그를 통해 jwt 에 대해 공부를 했다. 인증에 관한 부분을 프론트에 저장해야 되는데 우리 프로젝트는 자동로그인을 구현하기로 했으므로 브라우저를 나갔다 들어와도 토큰이 사라지지 않도록 토큰은 localStorage에 저장하였다. 하지만 이부분은 보안적으로 XXS 공격에 취약하긴 하다. 하지만 이번 프로젝트에는 localStorage에 토큰을 저장하는것으로 마무리 했다. (다른 방법을 찾아봤을 때 액세스 토큰은 변수에 저장하고 리프레쉬 토크은 httpOnly로 쿠키로 저장해 js에 대한 직접적인 접근을 막는 방법도 알아보았지만 이부분은 다음에 더 공부하고 시도해볼 것이다. )
SetToken.js
import axios from 'axios';
import moment from 'moment';
import { baseUrl } from '../../constants';
async function SetToken() {
// 토큰 및 만료 시간 가져오기
const accessToken = localStorage.getItem('token');
// const accessTokenExpireTime = moment.utc(localStorage.getItem('atkTime'));
const accessTokenExpireTime = moment(localStorage.getItem('atkTime'));
// 현재 시간과의 차이 계산
const diffTime = moment.duration(accessTokenExpireTime.diff(moment()));
// 토큰 만료 10초 전에만 처리
if (diffTime.asSeconds() < 10) { // asSeconds() 메서드를 사용하여 초 단위로 변환
try {
// 리프레시 토큰 가져오기
const refreshToken = localStorage.getItem('rtk');
// 서버에 액세스 토큰 갱신 요청
const response = await axios.post(`${baseUrl}/users/reissue`,{}, {
headers: {
'rtk': refreshToken // 리프레시 토큰 헤더에 설정
}
});
// 새로운 액세스 토큰 및 만료 시간 저장
const newAccessToken = response.data.result[1].token;
const newAccessTokenExpireTime = response.data.result[1].tokenExpiresTime;
localStorage.setItem('token', newAccessToken); //token === atk
localStorage.setItem('atkTime', newAccessTokenExpireTime);
// axios의 헤더에 새로운 액세스 토큰 설정
axios.defaults.headers.common['atk'] = newAccessToken;
} catch (error) {
// 토큰 갱신 실패 시, 로그인 페이지로 리디렉션 또는 다른 작업 수행
console.error('Token reissue failed:', error);
// 로그인 페이지로 리디렉션
}
}
return true;
}
export default SetToken;
액세스 토큰이 유효한지 확인하기 위해 SetToken이라는 함수를 만들어 사용하였다.
SetToken함수는 액세스 토큰의 유효 시간을 현재 시간과 계산해서 토큰 만료 10초 전에 백엔드로 리프레시 토큰을 담아 reissue 를 보내서 새로운 액세스 토큰을 발급받고 등록하도록 작성했다.
2. 방명록 페이지네이션
페이지네이션 또는 무한스크롤 중 하나를 선택해 방명록 페이지에 적용하기로 했는데 공유가 빈번히 일어나는 프로젝트의 특성 상 방명록의 특정 글을 공유하는 경우가 있다면 무한스크롤 보단 페이지네이션이 더 적합하다고 판단하여 페이지네이션으로 구현하였다.
백엔드에서 데이터 요청을 받기 전에 혼자서 더미 데이터로 페이지네이션을 구현해보면서 연습해보기도 했다.
우리 프로젝트에서는 모바일 크기로 개발되었기 때문에 한페이지에 5개의 글만 보이도록 설정했다.
동적으로 버튼을 생성하기 위해 props로는 전체 페이지를 받아서 계산을 통해 알맞은 개수의 버튼을 만들어주었다.
버튼은 총 5개까지 보이도록 설정했고 만약 8페이지까지 존재한다면 1~5 버튼이 존재했을 때 다음(>)버튼을 클릭 시 6 7 8 버튼이 존재하도록 구현하고 이전(<)버튼을 클릭 시 1 2 3 4 5 버튼이 존재하도록 구현하였다.
Pagination.jsx
import React, { useEffect ,useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {styled} from 'styled-components';
import { GrFormPrevious } from "react-icons/gr";
import { GrFormNext } from "react-icons/gr";
const Container = styled.div`
width: 100%;
display: flex;
justify-content: center;
align-items: start;
flex-wrap: wrap;
position: relative;
bottom:0px;
`
const ButtonContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin: 10%;
`
const PagesBtn = styled.button`
width: 25px;
height: 25px;
border:none;
background-color: white;
border-radius: 5px;
margin: 1px;
&:active{
color: #4c92fcaf;
}
&:hover{
color: #4c92fcaf;
}
`
const PrevNextBtn = styled.button`
width: 25px;
height: 25px;
display: flex;
justify-content: center;
align-items: center;
border:none;
border-radius: 5px;
background-color: #4c92fcaf ;
margin: 5px;
`
const Pagination = ({totalpages}) => {
const [currentPage, setCurrentPage] = useState(1); //현재 페이지
const totalPages = totalpages;
const maxPageButtons = 5;
const navigate = useNavigate();
const renderPageNumbers = () => { // 페이지네이션 버튼 구현 함수
const pageNumbers = [];
const startPage = ((Math.ceil(currentPage / maxPageButtons) - 1) * maxPageButtons) + 1;
const endPage = Math.min(startPage + maxPageButtons - 1, totalPages);
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return pageNumbers;
};
const handleClickPrevBtn = () => {
setCurrentPage(prevPage => Math.max(prevPage - maxPageButtons, 1));
navigate(`?page=${currentPage}`);
};
const handleClickNextBtn = () => {
setCurrentPage(prevPage => Math.min(prevPage + maxPageButtons, totalPages));
navigate(`?page=${currentPage}`);
};
const handleClickCurrentPage = (page) => {
setCurrentPage(page);
// fetchPagesData(page);
navigate(`?page=${page}`, { replace: true });
}
return (
<Container>
<ButtonContainer>
<PrevNextBtn onClick={handleClickPrevBtn} disabled={currentPage === 1}>
<GrFormPrevious size={25}/>
</PrevNextBtn>
<div>
{renderPageNumbers().map(page => (
<PagesBtn key={page} onClick={() => handleClickCurrentPage(page)}>
{page}
</PagesBtn>
))}
</div>
<PrevNextBtn onClick={handleClickNextBtn} disabled={currentPage === totalPages}>
<GrFormNext size={25}/>
</PrevNextBtn>
</ButtonContainer>
</Container>
);
};
export default Pagination;
3. react-router-dom 6.4v 사용
기존에 사용했던 react-router-dom 버전 말고 router 객체를 사용하는 react-router-dom 6.4v을 사용해보았는데 제대로 적용하지 못한 것 같아 아쉽다. (이와 관련되어 더 자세히 공부하고 블로그 글을 작성할 것이다. ) 레이아웃을 분리시킨다던가 완전한 기능을 제대로 사용하지 못하고 적용시킨 것 같아서 아쉽다. 하지만 새로운 기술은 알았기 때문에 다시 이부분은 공부해서 리팩토링 할 예정이다.
main.jsx
import React from 'react'
import { RecoilRoot } from 'recoil';
import ReactDOM from 'react-dom/client'
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import App from './App.jsx'
import ErrorPage from './pages/ErrorPage.jsx';
import './index.css'
import SettingPage from './pages/SettingPage.jsx';
import Oauth from './components/Login/Oauth.jsx';
import HomePage from './pages/HomePage.jsx';
import Board from './components/Home/Board.jsx';
import EditPage from './pages/EditPage.jsx';
import UpgradePage from './pages/UpgradePage.jsx';
import BookmarkPage from './pages/BookmarkPage.jsx';
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage/>,
},
{
path: "/setting/:id",
element: <SettingPage/>,
errorElement: <ErrorPage/>,
},
{
path: "/oauth",
element: <Oauth/>,
errorElement: <ErrorPage/>,
},
{
path: "/home/:id",
element : <HomePage/>,
errorElement: <ErrorPage/>,
},
{
path: "/board/:id",
element: <Board />,
errorElement: <ErrorPage/>,
},
{
path: "/edit/:id",
element: <EditPage/>,
errorElement: <ErrorPage/>,
},
{
path: "/upgrade/:id",
element: <UpgradePage/>,
errorElement: <ErrorPage/>,
},
{
path: "/bookmark/:id",
element: <BookmarkPage/>,
errorElement: <ErrorPage/>,
},
]);
ReactDOM.createRoot(document.getElementById('root')).render(
<RecoilRoot>
<RouterProvider router={router} />
</RecoilRoot>
)
😢 아쉬웠던 부분
1. 상태관리를 제대로 하지 못함
전역 상태관리 라이브러리로 Recoil을 선택했지만 막상 사용하지는 않아서 아쉬웠다. 대부분의 데이트를 백엔드에서 받아서 사용해서 의존도가 너무 높았던 것 같기도 하고 나중에 서버에 대한 부담이 클 것 같다는 아쉬움이 남았다. 로그인 상태를 전역으로 관리하려고 했는데 막상 모든 페이지에서 헤더에 액세스 토큰을 전달해주도록 설계되어 로그인 상태를 관리할 필요가 있을까 ? 어짜피 로그인 되어야 페이지를 이용할 수 있는데 ? 하는 생각이 들어 사용하지 않기도 했고 어디에 사용해야 할지 제대로 설계를 못한 것 같아 아쉬움이 남는다.
2. 가독성이 나쁜 코드
코드를 작성하다보니 반복되는 코드가 많은 것을 확인했고 컴포넌트도 완벽하게 나누지 못한 것 같다는 생각이 들었다.
방명록 페이지 코드만 해도 하나의 컴포넌트에 400줄이 넘는 코드가 작성되었는데 비슷한 코드가 3번 반복되어 이 부분을 컴포넌트로 나누어서 작성했으면 더 좋았을 것 같다는 아쉬움이 남는다.
3. 개발자의 입장에서 개발하기
기능을 개발하면서 이런 기능 어때 ? 했을 때 뭔가 너무 시간이 오래걸릴 것 같다 or 있으면 사용자는 좋겠지만 너무 헤비한 것 같다. 등 개발자의 입장에서 기능을 제한한 점이 많았다. 예를 들어 사용자 검색이라던지 팔로우 팔로잉 기능이라던지 (후에 팔로우 기능은 만들긴 했다.) 개발하기 편한 위주로 개발을 진행했던 것 같아서 아쉬움이 남는다.
👏 전체적으로 프로젝트를 마무리하며
프론트 1명 백엔드 1명에서 진행한 프로젝트 인 만큼 혼자서 모든 부분을 개발해야 한다는 부담감과 편안함이 공존해서 커밋이라던지 프로젝트 기한이라던지 지키지 않은 것들이 있어서 아쉬움이 있었다. 또한 기획부터 개발까지 모든 부분을 진행하여서 개발하면서 기획에서 벗어난 부분도 존재하고 바뀐부분도 존재해서 개발하는데 조금은 힘들었던 것 같다. (구르미 그리는 게 가장 힘들었음 )
하지만 그만큼 더 성장할 수 있었던 계기가 된 것 같다. 이번 프로젝트는 전체적으로 클레이모피즘을 적용해서 몽글몽글하고 입체적인 느낌이 나도록 제작하였는데 마음에 쏙 들었고 그래도 계획했던 기능들은 다 개발 할 수 있어서 정말 뿌듯했다.
또한 정확하게 정보를 전달하는게 정말 중요하다고 느꼈고 , 문서화의 필요성을 뼈저리게 느꼈다. (api문서가 늦게 나와서 개발된 기능을 대화로 구조에 대해 주고받은 적이 많은데 백엔드에서 post 요청 시 body로 content를 보내달라 했는데 나는 객체로 {content : content }로 보내서 요청이 실패했던 적이 정말 많았다... ㅜㅜ 만약 문서로 보았다면 이런 실수는 적었을 것 같긴하다 .. ! )
그리고 이번 프로젝트는 프론트에서만 배포하고 백엔드에서는 로컬 서버를 연동하여 실행하였는데 나중에 백엔드에서도 배포했을 때 발생할 비용이 걱정되긴하다 .. (과도한 get 요청이 걱정됩니다. .. )
하지만 .. 결국은 다양한 시행착오 끝에 성공적으로 프로젝트를 마무리할 수 있었고 많은 것을 알아가고 복습해볼 수 있던 프로젝트였던 것 같다.
프로젝트 회고 끝 !
'프로젝트' 카테고리의 다른 글
[프로젝트] SingK 를 마치고 쓰는 회고 (0) | 2024.09.19 |
---|---|
[프로젝트] 회원정보 조회 및 수정 시 리액트 쿼리 사용하기 ! (0) | 2024.06.11 |
useQuery 사용 시 Error: No QueryClient set, use QueryClientProvider to set one 에러 해결 (1) | 2024.06.11 |
[프로젝트] SingK 프로젝트 돌입 ! (0) | 2024.05.04 |
[프로젝트] '구르미 월드' 프로젝트 돌입 ! (1) | 2023.12.20 |