- Published on
My Profile Project develop | 개인프로젝트 공사기록
프로젝트 깃허브 주소 👉 https://github.com/fun1ty/myProfile
프로젝트 배포 ⚡ https://real-my-profile.vercel.app/
Intro
앞전에 개발하였던 개인프로젝트를 좀 더 디벨롭 하기 위해 약 2주간 이런저런 공부를 진행하였다. 이 포스팅은 그 과정을 기록하고자 한다.2주간 무엇을 삽질했나?
- three.js
- react-three-fiber
- tailwindCSS
- redux toolkit-thunk
- supabase
공부..라고 하기엔 좀 민망하고, 이것저것 건드려본 것들이다.
three.js & React-three-fiber
일단 지난번에 가장 큰 문제였던 3d렌더링의 렌더링 성능이 정말 최악인 것이 공부의 계기가 되었다. 크롬 브라우저-> 개발자도구(Network)로 확인해보니 렌더링 되는데 약 7초~9초 걸렸다..( 올해에 산 내 노트북이 이정도라면 그 보다 더 이전 노트북환경에서 얼마나 걸릴지 상상조차 하고싶지 않다..) 그래서 가장 먼저 three.js 라이브러리를 공부하였다. 우선 노드 환경에서 실행하려면 노드 버전을 10으로 다운그레이드 해야한대서 다운그레이드를 했다. 그래서 이것저것 조명, 빛, 카메라 등등을 얼추 공부하다가 (이벤트까지는 진행 못했다..ㅠ) 뭔가 이대로 계속 공부해도 영 감을 못잡을거 같아서 React-three-fiber을 공부하려고 알아봤는데 three.js의 개념만 같고 문법이나 이런건 전혀 다른거 같은 느낌이였다. 공식문서를 보긴했는데 어떻게 써먹어야 할지 전혀 감이 안와서 유투브를 찾아보았다. 슬프게도 국내자료가 없었다. three.js도 그렇고 국내쪽엔 자료가 너무 부족하다는 것을 느꼈다. 그렇게 유투브 검색을 하다가 갓 영상을 알게 되었다.👉React Three Fiber tutorial : Scroll Animations
영상에서는 프로젝트 폴더안에 models라는 폴더를 만들어서 glb파일을 넣고 터미널에
npx gltfjsx publics/models/WawaOffice.glb 명령어를 입력하면 자동으로 react-three-fiber 형식으로 생성되어진다. 이렇게 하면 spline링크로 바로 가져오는것 보다는 훨씬 빨라질 것 같아서 나 역시 glb파일을 export하려 했다. 우리가 모든게 무료인줄 알았습니까?
...
결국 선택한 최적화 방법
위의 이유로 spline 공식문서를 뒤졌으나 디자인툴의 목적이 강해서 인지 코드에 대한 내용은 없었고, 최적화를 하려면 불필요한 오브젝트나 조명, 컬러를 삭제해서 절대적인 용량을 줄이라는 말밖에 없었다. 결국 오브젝트를 제거했다.
- 메인페이지의 돼지를 삭제했다. 그리고 2마리의 돼지에게 있던 애니메이션도 삭제했다.
- 가장 랜더링이 말썽이던 /inhouse 페이지에서 요소를 대거 지워야했다. 애니메이션도 줄이거나 없앴다.
- spline에 음악을 심어놓은 것도 랜더링 성능을 떨어뜨리는 요소가 된 것 같아서 아예 리액트로 음악플레이어를 구현하였다.
구현방법은 아래와 같다. 우선 src폴더에 music 폴더를 만들어 mp3 파일을 넣었다.
그리고 musicPlayer.jsx 파일을 만들어 코드를 작성했다.
//musicPlayer.jsx
import { useState, useEffect } from "react";
import styled, { keyframes, css } from "styled-components";
const useAudio = (url) => {
console.log("url", url);
const [song] = useState(new Audio(url));
const [play, setPlay] = useState(false);
const toggle = () => setPlay(!play);
useEffect(() => {
play ? song.play() : song.pause();
}, [play]);
useEffect(() => {
song.addEventListener("ended", () => setPlay(false));
return () => {
song.removeEventListener("ended", () => setPlay(false));
};
}, []);
return [play, toggle];
};
const MusicPlayer = ({ url }) => { //props로 음악폴더경로를 받을 수 있도록 설정
const [playMusic, setMusic] = useAudio(url);
const rotateAnimation = keyframes`
//스타일컴포넌트 애니메이션. 클릭하면 꽃이 돌아가는 부분
from{
transform: rotate(0deg)
}
to{
transform: rotate(360deg)
}
`;
const Icon = styled.svg`
width: 20px;
height: 20px;
fill: currentColor;
animation: ${({ isPlaying }) =>
isPlaying
? css` // 이 부분을 반드시 css로 설정해야함
${rotateAnimation} 2s linear infinite
`
: "none"};
`;
return (
<div>
<Wrapper>
<button onClick={setMusic}>
<Icon
isPlaying={playMusic}
..부트스트랩 아이콘 부분이라 생략..
</Icon>
</button>
</Wrapper>
</div>
);
};
export default MusicPlayer;
const Wrapper = styled.div`
button {
all: unset;
cursor: pointer;
}
`;
해당 ul에 서로 다른 mp3 파일을 import하여 props로 전달하는 형식으로 MusicPlayer.jsx 컴포넌트를 재사용하였다.
//App.jsx
import MusicPlayer from "./musicPlayer";
import mp3 from "./music/main.mp3"; //mp3파일 불러오기
export default function App() {
return (
<Wrapper>
<h1>My Profile</h1>
<Flower>
<p className="Developer">
<MusicPlayer url={mp3} /> <p className="blank"></p>꽃을 클릭하세요♬ //음악재생플레이어
</p>
</Flower>
<Spline
className="spline"
scene="https://prod.spline.design/jOg6QiwDPAkaHyyZ/scene.splinecode"
/>
</Wrapper>
);
}
- 빠른 랜더링을 위해 vite환경으로 빌드했다.
이러한 일련의 과정들을 통해서 확실히 저번보다 빨라짐을 느꼈다. /inhouse페이지의 경우 7초-9초가 걸렸으나 vite로 띄운 후로는 4초-5초에 뜬다. 물론 3초 이내로 줄이는게 목표이기 때문에 또 다른 방법이 없는지 계속 찾고 있다.
tailwindCSS
한번 써봐야지 하다가 👉React Three Fiber tutorial : Scroll Animations 이 영상 클론코딩하면서 써봤다. 왜 tailwindCSS를 사용하나 했더니 1.클래스명을 고민할 필요가 없고 2. css 파일을 별도로 관리할 필요가 없으며 3.CSS 파일의 크기를 최소화하는 JIT(Just-In-Time) 컴파일러를 제공하여 성능을 최적화 할 수 있다. 일단 1,2의 장점은 사용하면서 확실히 체감하였고 3은 3d 렌더링 같은 무게(?)가 있는 요소들을 렌더링하는것에 확실히 좋아보인다. 몇번 더 써보면 능숙하게 쓸 수 있을것 같아 곧 있을 최종 프로젝트에서 제대로 써볼까 한다.redux toolkit-thunk
기존 /visit 페이지는 redux toolkit으로 상태관리를 했었다. 백엔드와 데이터를 주고 받으려면 thunk라는 미들웨어를 통해 비동기통신을 해야한다기에 사용해보았다. thunk는 createAsyncThunk와 extraReducers를 이용한다. 이 프로젝트 같은 경우, createAsyncThunk를 이용해 CRUD를 구현하고 extraReducers로 액션에 대한 리듀서를 작성하였다.configureStore로 redux store를 생성한다.
import { configureStore } from "@reduxjs/toolkit";
import writeSlice from "./VisitWrite";
const store = configureStore({
reducer: { visitWrite: writeSlice },
});
export default store;
아래는 전체 DB를 조회하는 부분이다. (asyncUpDBFetch)
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
export const asyncUpDBFetch = createAsyncThunk(
"visitWrite/asyncUpDBFetch",
async () => {
const data = await supabase
.from("guestBook")
.select("*")
.order("id", { ascending: true });
return data.data;
}
);
asyncUpDBFetch에 대한 extraReducers 부분이다.
extraReducers: (builder) => {
//초기 DB불러오기
builder.addCase(asyncUpDBFetch.pending, (state, action) => {
state.status = "loading";
});
builder.addCase(asyncUpDBFetch.fulfilled, (state, action) => {
state.array.push(action.payload);
state.status = "complete";
});
builder.addCase(asyncUpDBFetch.rejected, (state, action) => {
state.status = "fail";
state.error = action.error.message;
});
}
이렇게 가져온 DB는 dispatch가 일어날 때마다 프론트에 데이터를 가져온다.
import {insertUserAsync,deleteUserAsync,updateUserAsyncasyncUpDBFetch,} from "./store/VisitWrite";
const dispatch = useDispatch();
const DBList = useSelector((state) => state.visitWrite.array);
useEffect(() => {
dispatch(asyncUpDBFetch());
}, [dispatch]);
<table>
<tbody>
{DBList[0] &&
DBList[0].map((value) => {
return (
<tr key={value.id}>
<td className="vistitMsg">
{value.context ? value.context : value.guestMessage}
</td>
<td className="writer">작성자: {value.name}</td>
<td className="edit">
<button
className={"updateModal-open-btn"}
onClick={() =>
updateMessage(value.id, value.context, value.name)
}
>
수정
</button>
</td>
</tr>
);
})}
</tbody>
</table>
그러면 화면에 이렇게 보인다.
supabase
DB를 뭘로 쓰지 고민을 했다. AWS RDS도 생각했지만 좀 더 쉽게 이용할 수 있는 서비스가 없나 이것저것 검색하던차 알게된 Baas이다. Firebase 를 안써봐서 잘 모르겠지만 Firebase보다 더 편하다고 했고, 실제로 사용해보니 공식문서도 잘되어 있어 활용하기 편했다.supabase 활용
우선 supabase > project> settings에 들어가서 Project URL/ Project API keys를 copy한다.
그 다음 src폴더에 createClient.js 파일을 만들어서 복사한 url와 API key를 붙여넣는다. .env 파일을 만들어 넣었는데 react 환경변수 설정하는 것처럼 process.env 로 하면 안되고 vite환경에 맞춰서 import.meta.env.VITE 로 설정해야 환경변수 적용이 된다.
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_API_KEY,
{
realtime: true,
}
);
설정한 supabase를 thunk 함께 사용했다.
참고로 createSlice에 정의된 name("visitWrite")과 createAsyncThunk의 name의 앞부분("visitWrite/updateContext") 이 동일해야한다. 아래는 update 구문이다.
import { supabase } from "../createClient";
export const updateUserAsync = createAsyncThunk(
"visitWrite/updateContext",
async (updateData) => {
const data = await supabase
.from("guestBook")
.select("password")
.eq("id", updateData.id);
if (updateData.password === data.data[0].password) {
await supabase
.from("guestBook")
.update({
context: updateData.context,
})
.eq("id", updateData.id);
return true;
} else {
return false;
}
}
);
배포하기
vite환경으로 vercel에 배포했다. 배포하고 난 뒤 메인페이지는 잘 뜨는데 다른페이지로 넘어갈때마다 404 에러가 뜨길래 찾아보니까 루트폴더에 vercel.json 을 추가하여 아래와 같이 의존성을 추가해서 띄우면 된다. vercel.json파일을 설정했음에도 설치한 vercel cli에서 확인을 했는데 계속 아무페이지도 안나와서 vercel.json을 몇번이나 수정했다가 결국 그냥 저 코드 추가하고 띄워나보자 라는 마음으로 깃푸시를 하니 고쳐졌다...{
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
}
발생한 문제
/visit페이지에 CRUD가 되면 DB가 변경된 상태가 화면에 바로바로 렌더링이 안되고 새로고침해야지 화면에 렌더링되는 문제가 있어 엄청나게 삽질을 했다.
시도해 본 것은 아래와 같다.
- useEffect에서 dispatch될때만이 아닌 계속 렌더링되게 한다.
- 이에 대한 코드이다.
useEffect(() => {
dispatch(asyncUpDBFetch()).then((action) => {
if (asyncUpDBFetch.fulfilled.match(action)) {
setDBuser(action.payload);
console.log("DB", action.payload);
}
});
});
이 방법을 쓰면 위의 문제가 해결은 되나, 문제는 무한히 재렌더링한다는 단점이 있다. console.log에 찍어보니 끝없이 랜더링되더니 결국 에러남
- useSelector 와 dispatch를 같이 사용해서 변화를 감지한다.
const DBList = useSelector((state) => state.visitWrite.array);
useEffect(() => {
dispatch(asyncUpDBFetch());
}, [dispatch]);
소용없었다.
- DB 변화한 상태를 실시간으로 감지해 프론트에 보낸다.
- supabased에는 realtime이라는 기능을 이용하여 DB의 변화를 실시간 '구독'할 수 있다. socket.io 라이브러리를 위한 기능같아 보이는데 어쨌든 이 기능을 이용해보기로 했다.
realtime을 이용하려면 supabase에 세팅을 해야한다.
- supabase > dashboard > project > database > Replication
Replication 에서 realtime을 활성화 시키고 싶은 테이블을 선택하여 활성화 시킨다.
혹은 Table Editor에서도 설정할 수 있다.
그리고 realtime에 대한 공식문서의 코드를 참고하여 작성했다.
- .channel("schema-db-changes") - 채널 이름은 'realtime'을 제외한 모든 문자열이 될 수 있다. (테이블명 아님)
- .on("postgres_changes",..) - 포스트그레스 변경 사항으로 데이터베이스 변경사항을 수신하고 클라이언트에 보낸다. 정해진 문법이므로 반드시 이렇게 설정해야한다.
- event: "*" - 모든 이벤트
- schema: 'public' - realtime으로 데이터베이스 설정을 하면 스키마가 public됨
- table: "mytest" - 실시간 수신받고자 하는 DB테이블명
import { supabase } from "../createClient";
const processedEvents = new Set();
export const subscribeToDBData = () => async (dispatch) => {
const channel = await supabase
.channel("schema-db-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "mytest" },
(payload) => {
if (processedEvents.has(payload.id)) {
return; //중복된 이벤트 방지
}
console.log("DBpayload", payload);
if (payload.eventType === "INSERT") {
dispatch(insertUserAsync(payload.new));
} else if (payload.eventType === "UPDATE") {
dispatch(updateUserAsync(payload.new));
} else if (payload.eventType === "DELETE") {
// dispatch(deleteUserAsync(payload.old));
}
processedEvents.add(payload.id);
}
)
.subscribe(); //DB상태를 실시간으로 구독
};
프론트에서 삭제한게 아닌, supabase 에서 DB를 삭제했을 때 바로 콘솔로 찍혔다.
그러나 화면에 바로 렌더링되진 않았다..
이 밖에도 useEffect와 useMemo를 같이 사용한다던가... 여러가지 방법을 써봤으나 모두 되지 않아 결국 CRUD가 일어나면 강제로 새로고침되게끔 만들어놓은 상태였었다.
해결은 했으나...?
extraReducers에 있는 상태값을 추가하여 해결하였다.이것은 리더님께 물어봐서 해결을 한 것인데 리듀서의 기본프로세스를 내가 간과하고 있었다..extraReducers에 R,U 기능만 상태값을 보내고 I,D는 보내지 않아 상태가 업데이트 되지 않고 있었기 때문에 렌더링이 되지 않았던 것이였다. 따라서 아래와 같이 코드를 수정하였다.
extraReducers: (builder) => {
//초기 DB불러오기
builder.addCase(asyncUpDBFetch.pending, (state, action) => {
state.status = "loading";
});
builder.addCase(asyncUpDBFetch.fulfilled, (state, action) => {
state.array = action.payload;
state.status = "complete";
});
builder.addCase(asyncUpDBFetch.rejected, (state, action) => {
state.status = "fail";
state.error = action.error.message;
});
//insert
builder.addCase(insertUserAsync.pending, (state, action) => {
console.log("insertUserAsync");
state.status = "loading";
});
builder.addCase(insertUserAsync.fulfilled, (state, action) => {
state.array.push(action.payload); //불변성 유지를 위해 push
state.status = "complete";
});
builder.addCase(insertUserAsync.rejected, (state, action) => {
state.status = "fail";
state.error = action.error.message;
});
//update
builder.addCase(updateUserAsync.pending, (state, action) => {
console.log("updateUSerAsync");
state.status = "loading";
});
builder.addCase(updateUserAsync.fulfilled, (state, action) => {
const updatedData = action.payload;
const itemIndex = state.array.findIndex(
(item) => item.id === updatedData.id
);
if (itemIndex !== -1) { //수정된 데이터만 배열에 붙이기
state.array[itemIndex] = { ...state.array[itemIndex], ...updatedData };
}
state.status = "complete";
});
builder.addCase(updateUserAsync.rejected, (state, action) => {
state.status = "fail";
state.error = action.error.message;
});
//delete
builder.addCase(deleteUserAsync.pending, (state, action) => {
console.log("deleteUserAsync");
state.status = "loading";
});
builder.addCase(deleteUserAsync.fulfilled, (state, action) => {
const deletedId = action.payload;
state.array = state.array.filter((item) => item.id !== deletedId);
state.status = "complete";
});
builder.addCase(deleteUserAsync.rejected, (state, action) => {
state.status = "fail";
state.error = action.error.message;
});
},
그리고 useMemo를 사용해 불필요한 렌더링을 방지했다.
const dispatch = useDispatch();
const DBList = useSelector((state) => state.visitWrite.array);
console.log("DBList", DBList);
// dispatch(subscribeToDBData());
const memoizedDBList = useMemo(() => DBList, [DBList]);
useEffect(() => {
const fetchData = async () => {
const result = await dispatch(asyncUpDBFetch());
if (asyncUpDBFetch.fulfilled.match(result)) {
console.log("Data fetched successfully!");
} else {
console.error("Data fetch failed:", result.error);
}
};
fetchData();
}, [memoizedDBList]);
움.. 근데 이것도 썩 좋은 해결책은 아닌거 같은게 개발자도구> 콘솔창에서 확인해보면 불필요한 렌더링이 계속 생기는 현상이 있다. 계속 공부를 해서 다른 방법이 없는지 알아봐야 할것 같다.
Review
모자란 부분을 많이 깨달은 시간이였다. 이제 기능은 얼추 구현되었으니 계속 develop 작업만 남았다./visit페이지는 디자인 전부 마음에 들지 않아서 어떻게 만들어야할지 계속 고민중이고 jwt 인증 기능도 만들예정이다.
