F.ROCK 쇼핑몰 프로젝트 회고✍️
F.ROCK 쇼핑몰은 Momentum과 PazizicTV를 제작하는 과정에서 부족함을 느낄 때마다 새롭게 도전하며 탄생한 프로젝트입니다. 기능 구현과 UI 디자인에서 더 나은 방법이 떠오를 때마다 처음부터 다시 설계하거나 기존 코드를 완전히 덮어쓰는 과정을 반복하며 두 차례에 걸쳐 발전시킨 결과물입니다.
이 프로젝트를 통해 얻은 지식과 깨달음은 제게 큰 의미가 있었습니다. 처음 구상할 때 예상하지 못했던 다양한 기술적 통찰을 경험하며, 프론트엔드 개발자로서 한층 더 성장할 수 있었습니다.
""
어제는 디자이너였다가,
서른에 처음 개발을 배워서 서른 셋인 지금 개발자로 일한다는 여성을 만났다.
늦었다는 사람 따로 있고, 그냥 해버리는 사람 따로 있고,
그 둘은 완전히 다른 세상을 산다.
""
1. F.ROCK 쇼핑몰 소개
F.ROCK 쇼핑몰은 사용자와 관리자의 역할을 명확히 구분한 e커머스 플랫폼입니다. 일반 사용자는 다양한 제품을 탐색하고 장바구니에 담을 수 있으며, 결제 시스템을 통해 실제 구매를 진행할 수 있습니다.
관리자는 제품을 효과적으로 관리할 수 있는 기능을 갖추고 있어, 신규 제품 추가 및 기존 제품 삭제가 가능합니다. 또한, 제품 카테고리를 명확히 구분하여 사용자가 필요한 상품을 쉽게 찾을 수 있도록 설계하였습니다.
이 사이트는 Firebase를 활용하여 로그인 기능과 상품 데이터 관리를 통합했으며, 사용자 경험을 고려한 반응형 디자인을 적용하여 다양한 기기에서 최적화된 화면을 제공합니다. 또한, 모든 제품은 두 가지 방식으로 선택하여 볼 수 있도록 레이아웃을 구성하여 사용자에게 다양한 탐색 옵션을 제공합니다.
2. 문제점 및 어려웠던 점과 어떻게 해결하였는지에 대해서
#1. 페이지 경로 보호와 비동기 처리
로그인 상태를 확인 후 user의 isAdmin값이 true/false인지를 파악하여 관리자와 일반 유저를 구분하고, 프로텍티드 라우트에서 이를 처리하여 자식 컴포넌트를 반환하는 과정이다. 이 과정은 온라인이 나와있는 ProtectedRoute로 경로 보호하는 방법을 찾아보면 어렵지 않게 할 수 있는데 왜 어려웠나를 말하자면 어렵지는 않았다. 다만 하나의 문제로 "왜 안 되지?!"를 생각하고 교훈을 얻을 수 있었던 문제였다.
우선, 인증 상태 관찰자 설정 및 사용자 데이터를 가져오기 위해 파이어베이스의 onAuthStateChanged 메서드를 이용하면 앱 페이지마다 전역 인증 객체에 관찰자를 연결하여 렌더링이 완료된 후 비동기적으로 사용자에 대한 정보를 가져올 수 있다.
Firebase에서는 사용자의 로그인 상태를 세션 단위로 관리한다. 사용자가 로그인하면 Firebase는 해당 사용자의 인증 정보를 브라우저의 로컬 스토리지에 저장한다. 그러나 페이지를 새로 고침할 때, 인증 정보가 제대로 로드되지 않으면 상태가 풀리는 것처럼 보일 수 있다.
onAuthStateChanged 메서드는 Firebase에서 제공하는 기능으로, 사용자의 로그인 상태가 변경될 때마다 호출되는 콜백 함수를 등록한다. 이 메서드는 다음과 같은 방식으로 작동한다.
- 초기화 : 앱이 로드될 때, onAuthStateChaned를 호출하여 현재 로그인 상태를 확인한다. 사용자가 이미 로그인되어 있는 경우, 해당 사용자 정보를 가져오고 애플리케이션의 상태를 업데이트한다.
- 상태 변경 감지 : 사용자가 로그인하거나 로그아웃할 때마다 이 메서드는 자동으로 호출된다. 이를 상태 관리가 가능해지며, UI를 적절하게 업데이트할 수 있다.
firebase.js
export function onUserStateChange(callback) {
onAuthStateChanged(auth, async (user) => {
callback(updatedUser);
});
}
위에서 반환받은 user의 데이터를 전역으로 사용하기 위해 context API를 이용했다.
onUserStateChange 메서드는 사이트 내에 렌더링이 되면 비동기적으로 실행되어 user의 값을 업데이트한다.
import { createContext, useContext, useEffect, useState } from 'react';
import { login, logout, onUserStateChange } from '../api/firebase';
export const AuthContext = createContext();
export function AuthContextProvider({ children }) {
const [user, setUser] = useState();
useEffect(() => {
onUserStateChange((user) => {
setUser(user);
});
}, []);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuthContext = () => useContext(AuthContext);
그러나 여기서 문제가 있었다.
로그인, 로그아웃을 한 상태에서 페이지를 새로 고침하여도 유지는 잘 되고, 버튼을 클릭하여 경로 보호가 되고 있는 페이지에도 잘 들어간다. 하지만 첫 렌더링이나 새로 고침을 한 상태에서 주소창에 경로 보호 페이지를 치면 들어가지지 않고 홈으로 리다이렉트 되는 것이다.
그동안 React Query를 아무 생각없이 습관적으로 사용하면서 비동기라는 사실을 잠시 망각하여 "왜지, 왜 안될까"만 10번은 말한 것 같다.
정말 반성하며, 지금이라도 다시 한번 비동기에 대해 알 수 있어서 다행이라 여겼다.
이 문제는 중간에 undefined가 출력되는 이유와도 같다.
setTimeout(()=> console.log(2+2), 1000)가 실행된다. setTimeout은 비동기 함수로, 자체로 호출은 되지만, 콜백 함수는 대기 시간이 지난 후에 실행된다. 고로 setTimeout 함수 자체는 아무런 값을 반환하지 않기 때문에 실행 결과로 undefined가 반환된다.
1. 상태 변화의 인식
리액트는 상태가 변할 때마다 컴포넌트를 재렌더링한다. 그러나 상태 변화가 일어나지 않는 경우, 리액트는 컴포넌트를 다시 렌더링 하지 않는다. 비동기 통신을 통해 데이터를 가져올 때, 그 데이터가 로드되는 동안 상태를 업데이트하지 않으면 리액트는 이 변화를 인식하지 못하게 된다.
2. 비동기 작업의 비동기성 ⭐
비동기 작업은 기본적으로 시간이 걸리는 작업이다. API 호출이 완료되기 전에 결과를 사용하려고 하면, 그 시점에서 상태가 변하지 않기 때문에 리액트는 변경 사항을 감지하지 못하는 것이다.
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
onUserStateChange((user) => {
setIsLoading(false);
setUser(user);
});
}, []);
isLoading 상태 변수를 사용하면 비동기 작업이 진행 중인 동안 상태를 명확히 인식할 수 있다.
#2. 여러 개의 사진 한번에 업로드
위의 공식 사이트에 나온 여러 개의 이미지 파일들을 업로드하여 URL로 반환받는 설명과 예제가 나와있다.
나의 경우, 여러 개의 이미지를 개별 URL로 받는 것이 아니라, 배열로 묶어 하나의 리턴값 안에 여러 개의 URL을 받고 싶었다.
위의 코드에서는 for루프를 사용하여 선택된 각 파일에 대해 fetch 요청을 개별적으로 실행한다. 이 경우, 각 파일의 업로드가 완료될 때마다 그 결과를 처리하기 때문에 모든 요청이 동시에 완료될 필요가 없다.
반면, 여러 개의 이미지 업로드 URL을 개별적으로 리턴값을 받는 것이 아닌 하나의 배열값에 받고 싶기 때문에 여러 요청이 한 번에 이루어져 한다. 즉 이미지 개수만큼 요청이 일어나는데, 이를 한 번에 기다렸다가 결과를 받아야 한다.
그러기 위해 Promise.all을 사용하거나 for 루프/await을 사용해 한다.
// Promise.all을 이용한 방법
export async function uploadImages(files) {
const uploadPromises = [...files].map(async (file) => {
const data = new FormData();
data.append('file', file);
data.append('upload_preset', process.env.REACT_APP_CLOUDINARY_PRESET);
try {
const response = await axios.post(
process.env.REACT_APP_CLOUDINARY_URL,
data
);
return response.data.url; // 각 파일의 URL 반환
} catch (error) {
console.error('이미지 업로드 중 오류 발생 : ', error);
throw error;
}
});
// 모든 업로드가 완료될 때까지 대기
return Promise.all(uploadPromises);
}
// for 루프/await을 이용한 방법
async function uploadImagesSequentially(files) {
const urls = [];
for (const file of files) {
const data = new FormData();
data.append('file', file);
data.append('upload_preset', process.env.REACT_APP_CLOUDINARY_PRESET);
try {
const response = await axios.post(
process.env.REACT_APP_CLOUDINARY_URL,
data
);
urls.push(response.data.url);
} catch (error) {
console.error('이미지 업로드 중 오류 발생 : ', error);
throw error;
}
}
return urls;
}
for루프/await방식은 비동기적(병렬적)으로 요청이 처리되는 것이 아닌, 순차적으로 처리되기 때문에 업로드 시간이 긴 바면, Promise.all을 사용하면 모든 파일 업로드 요청이 동시에 시작된다
위에 대한 내용으로 혼란이 많았다.
" Promise.all방법도 결국 map메서드로 배열 데이터를 하나 하나 요청하는 방식이라 결국 병렬처리가 아닌, 순차처리 아니야? "
이에 대한 말은 부분적으로 맞지만, 중요한 차이점이 있었다.
1. Promise.all 이용한 방법
1-1. Promise.all과 병렬 처리
Promise.all 을 사용하는 방식은 실제로 병렬 처리를 수행한다. map메서로 생성된 각각의 Promise는 동시에 시작된다!
즉, 모든 파일 업로드 요청이 거의 동시에 시작되며, 서버의 용량과 네트워크 상태에 따라 병렬로 처리된다.
1-2. map 메서드와 비동기 처리
map 메서드 자체는 동기적으로 작동하지만, 각 반복에서 생성되는 Promise는 비동기적으로 실행된다. 따라서
map은 빠르게 Promise 배열을 생성하고, 이를 Promise들은 동시에 실행되기 시작한다!
2. for 루프/await 이용한 방법
각 파일 업로드가 완전히 끝난 후에 다음 파일 업로드가 시작된다. 따라서 순차적이며 전체 처리 시간이 더 길어질 수 있다.
Promise대신 콜백 함수나 async/await을 주로 사용하여 해당 개념을 다시 공부해야 했다.
아무튼, Promise.all() 메서드는 여러 개의 프로미스를 입력받아, 모든 프로미스가 성공적으로 완료될 때까지 기다린 후, 각 프로미스의 결과를 배열로 반환하는 메서드이다. 만약 입력된 프로미스 중 하나라도 실패하면, Promise.all()은 즉시 실패하며, 그 실패한 프로미스의 이유를 반환한다.
#3. 장바구니에 상품을 등록하면 즉각 업데이트되게 하기
예를 들어, 상품을 장바구니에 넣는 메서드를 통해 파이어베이스의 Realtime Database에 저장하고, 장바구니 페이지로 이동 시 해당 유저의 uid를 확인해 carts라는 카테고리에 있는 상품 id를 가져와 보여준다.
장바구니 페이지에서 상품의 갯수를 지정할 수 있는데, 개수를 늘릴 경우 즉각 장바구니 페이지에 있는 상품을 불러오는 메서드를 다시 실행시켜 줘야 데이터의 변화가 즉각 컴포넌트에 보일 수 있다.
https://tanstack.com/query/latest/docs/framework/react/guides/invalidations-from-mutations
Invalidations from Mutations | TanStack Query React Docs
Invalidating queries is only half the battle. Knowing when to invalidate them is the other half. Usually when a mutation in your app succeeds, it's VERY likely that there are related queries in your application that need to be invalidated and possibly refe
tanstack.com
이를 위해 Tanstack/react-query의 Invalidations from mutations를 이해해야 했다.
https://tkdodo.eu/blog/practical-react-query#create-custom-hooks
Practical React Query
Let me share with you the experiences I have made lately with React Query. Fetching data in React has never been this delightful...
tkdodo.eu
위의 tanstack/react-query의 사용방법으로 UI와 데이터 로직을 분리하는 것을 추천하는데, 리액트 쿼리를 이용한 쿼리 키와 쿼리 함수를 한 곳에서 관리하고, 여기서 invaidate하거나 캐싱, 스테일, 셀렉트 등등을 한 곳에서 처리할 수 있어 효율적이라 생각한다.
예를 들어, 파이어베이스에 Realtime Database에 등록된 상품들을 불러오는 Products컴포넌트에서 스테일 타임을 설정할 경우 새로운 제품을 등록해도 즉각 반영이 되지 않는 경우, 새로운 제품을 등록하는 컴포넌트에 제품을 불러오는 키를 가진 쿼리에 invalidation을 설정할 경우 즉각 업데이트를 시킬 수 있다.
invalidate를 몰랐을 경우 수량을 변경할 경우 이를 파이버베이스에서는 업데이트하여도 변경된 ui가 없으니 재렌더링이 발생하지 않아, 수량의 변화가 바로 일어나지 않는다.
하지만 invalidate를 이용해서 장바구니 페이지에서 제품을 수량을 업데이트할 경우 장바구니 페이지에서 파이어베이스의 carts항목을 불러오는 함수를 즉각 다시 호출하여 바로 바로 업데이트를 할 수 있다.
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { addNewProduct, getProducts } from '../api/firebase';
export default function useProducts() {
const queryClient = useQueryClient();
const productsQuery = useQuery({
queryKey: ['products'],
queryFn: getProducts,
staleTime: 1000 * 60,
});
const addProduct = useMutation({
mutationFn: ({ product, urls }) => addNewProduct(product, urls),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product'] });
},
});
return { productsQuery, addProduct };
}
위와 같이 커스텀훅으로 product을 보여주고, invalidate해줄 수 있는 메서드도 한 곳에 모아 관리할 수 있어. 유지보수가 훨씬 나아졌다.
나는 파이어베이스의 데이터가 변경되면 자동적으로 업데이트가 되어야 할 컴포넌트를 mutatuin 메서드를 사용하여 invalidation하여 재랜더링을 시키는 방법을 사용했다.
그러나 파이어베이스의 구독(subscribe)기능을 사용하는 것이 더 간단한 방법임을 알았다. 구독 기능에 대해 알고만 있었지, 사용할 생각을 그때 당시에는 떠올리지 못 했다.
Firebase Realtime Database에서 자동 업데이트가 되는 원리는 데이터의 변화가 생기면, 해당 데이터에 구독한 클라이언트들이 변경 사항을 자동으로 받아오는 것이다. 이렇게 하면 사용자가 별도의 작업 없이도 UI가 실시간으로 데이터의 변화를 반영할 수 있다.
리액트의 상태 관리 라이브러리와 연결할 때, Realtime Database에서 데이터를 실시간으로 구독하여 자동으로 리렌더링되도록 설정할 수 있다. 이렇게 하면 별도의 mutaion을 통해 invalidation작업을 하지 않아도 UI가 실시간으로 반응할 수 있다.
자동으로 업데이트되게 하기 위해서는 Realtime Database에서 제공하는 on메서드를 사용하여 데이터의 변경 사항을 실시간으로 감지하고, 상태를 업데이트해야 한다.
* onValue 메서드로 데이터 구독하기 (예제)
import { useEffect, useState } from 'react';
import { getDatabase, ref, onValue, off } from 'firebase/database';
const RealTimeDataComponent = () => {
const [data, setData] = useState(null);
const database = getDatabase();
useEffect(() => {
// 특정 데이터 경로에 대한 참조 생성
const dataRef = ref(database, '경로/데이터');
// 데이터가 변경될 때마다 onValue 메서드가 트리거됨
const unsubscribe = onValue(dataRef, (snapshot) => {
setData(snapshot.val()); // 상태를 업데이트하여 리렌더링 유도
});
// 컴포넌트가 언마운트될 때 구독 해제
return () => off(dataRef, 'value', unsubscribe);
}, [database]);
return (
<div>
{data ? <p>Data: {JSON.stringify(data)}</p> : <p>Loading...</p>}
</div>
);
};
2가지 방법이나 알아버렸잖아!⭐ 완전 럭키비키잖아...
3.☕후기
이론 공부 후 실습을 통해 프로젝트를 만들어보는 경험이 정말 많은 도움이 되었다. 완벽한 프로젝트를 완성하지 않아도 괜찮다. 중요한 것은 만들어 보고, 살을 붙이고, 더 나은 로직으로 수정할 수 있는지 고민하는 모든 과정이 소중하다는 점이다.
이 프로젝트를 마치고 나니, 다른 프로젝트에서 겪었던 다양한 문제들이 나중에 비슷한 상황에 직면했을 때 겁을 덜 먹게 해주고, 해결의 실마리가 되어주기도 했다. 이전 경험 덕분에 비동기 관련 문제 상황에 부딪히더라도 대처 방법에 대한 감이 잡히게 되었다.
'Project' 카테고리의 다른 글
[React Project] Momentum - Lighthouse 최적화 (5) | 2024.11.06 |
---|---|
[React Project] Momentum ver 1 .1 .1 리팩토링 과정 중 문제&해결 (0) | 2024.10.18 |
[React Project] Momentum ver 1 .0 .1 (0) | 2024.10.08 |
[React Project] 파지직TV⚡[Ver2.0] (0) | 2024.07.27 |
[React Project] Momentum (0) | 2024.05.25 |