
main.jsx
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(<App />);
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
1️⃣ QueryClient와 QueryClientProvider은 React Query의 핵심 요소
QueryClient : 요청, 응답, 캐시 등을 관리하는 중앙 관리자QueryClientProvider : 앱 전체에서 React Query를 사용할 수 있게 해주는 Provider 컴포넌트2️⃣ queryClient는 QueryClient()의 인스턴스
3️⃣ App.jsx를 QueryClientProvider로 감싸주기
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
import "./App.css";
import Header from "./components/Header";
import LikeCardList from "./components/LikeCardList";
import List from "./components/List";
import SkeletonList from "./components/SkeletonList";
import {
fetchAllRestaurants,
getUserLikes,
postUserLike,
deleteUserLike,
} from "./api/restaurantAPI";
import { sortPlacesByDistance } from "./utils/loc";
import { useEffect, useState } from "react";
function App() {
//맛집 데이터를 담을 상태 변수 선언
const [allRestaurants, setAllRestaurants] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [likedRestaurants, setLikedRestaurants] = useState([]);
//에러처리 상태 변수 선언
const [errorMessage, setErrorMessage] = useState(null);
// console.log("allRestaurants : ", allRestaurants);
useEffect(() => {
// 브라우저에서 현재 사용자 위치 요청
navigator.geolocation.getCurrentPosition(
(position) => {
// 위도, 경도 받아오기
const { latitude: userLat, longitude: userLon } = position.coords;
// 사용자의 현재 위치를 기준으로 맛집 목록을 거리순으로 정렬
if (
allRestaurants &&
Array.isArray(allRestaurants.places) &&
allRestaurants.places.length > 0
) {
const sorted = sortPlacesByDistance(
allRestaurants.places,
userLat,
userLon
);
// 정렬된 데이터를 상태에 저장(장소는 거리순으로 변경됨)
setAllRestaurants({ ...allRestaurants, places: sorted });
}
},
(error) => {
console.error("위치 정보를 가져오는 데 실패했습니다.", error);
}
);
//거리 순으로 상태가 변경될 때마다 렌더링
}, [allRestaurants]);
useEffect(() => {
const getData = async () => {
try {
const data = await fetchAllRestaurants();
setAllRestaurants(data);
} catch (err) {
console.error("요청 실패:", err);
if (err instanceof Response) {
if (err.status === 404) {
setErrorMessage("요청하신 데이터를 찾을 수 없습니다. (404)");
} else {
setErrorMessage(`문제가 발생했습니다. (상태 코드: ${err.status})`);
}
} else {
setErrorMessage("서버와 연결할 수 없습니다. 주소를 확인해주세요.");
}
} finally {
setIsLoading(false);
}
};
getData();
}, []);
useEffect(() => {
const data = async () => {
try {
const all = await fetchAllRestaurants();
// console.log("백엔드 응답 : ", all);
setAllRestaurants(all);
} catch (err) {
console.error("에러 발생!", err);
} finally {
setIsLoading(false);
}
};
data();
}, []);
//컴포넌트가 처음 화면에 나타났을 때 딱 한 번만 실행
useEffect(() => {
const data = async () => {
try {
//아까 반환한 JSON 형식을 서버에 요청을 보내 데이터를 받음
const all = await fetchAllRestaurants();
//데이터를 받고 상태 변경 적용
setAllRestaurants(all);
// console.log("all :", all);
} catch (error) {
console.log(error);
}
};
data(); //비동기 함수를 실행
}, []);
// console.log("state :", allRestaurants);
//찜목록
useEffect(() => {
const fetchLikes = async () => {
try {
const data = await getUserLikes();
setLikedRestaurants(data.places);
} catch (err) {
console.error("찜 목록 불러오기 실패!", err);
}
};
fetchLikes();
}, []);
const handleLikeToggle = async (restaurant) => {
const isLiked = likedRestaurants.some((item) => item.id === restaurant.id);
try {
if (isLiked) {
await deleteUserLike(restaurant.id);
setLikedRestaurants((prev) =>
prev.filter((item) => item.id !== restaurant.id)
);
} else {
await postUserLike(restaurant);
setLikedRestaurants((prev) => [...prev, restaurant]);
}
} catch (err) {
console.error("찜 토글 실패:", err);
}
};
return (
<div className="min-h-screen bg-[#121212] text-gray-200">
<Header />
<main className="max-w-6xl mx-auto px-4 py-8">
<section className="mb-8 bg-[#1e1e1e] p-8 rounded-xl border-[#333] shadow-xl">
<h3 className="text-xl font-semibold mb-4 text-center text-white">
❤️ 찜한 맛집
</h3>
<LikeCardList
likedRestaurants={likedRestaurants}
onToggleLike={handleLikeToggle}
/>
</section>
<section className="bg-[#1e1e1e] p-8 rounded-xl shadow-xl">
<h2 className="text-xl font-semibold mb-4 text-center text-white">
📍전체 맛집 목록
</h2>
{errorMessage && (
<div className="text-red-400 text-center my-4 font-medium">
{errorMessage}
</div>
)}
{isLoading ? (
<SkeletonList />
) : (
<List
allRestaurants={allRestaurants}
likedRestaurants={likedRestaurants}
onToggleLike={handleLikeToggle}
/>
)}
</section>
</main>
</div>
);
}
export default App;
1️⃣ 먼저 import 필요 없는 부분 삭제 및 추가
import { useEffect, useState } from "react";
React Query를 사용하게 되면 사용자가 직접 useEffect와 useState로 상태를 관리하지 않음React Query를 사용하기 위해 아래와 같이 import해준다.import { useQuery } from "@tanstack/react-query";
2️⃣ 다음으로 useState코드와 useEffect 코드를 합칠 수 있는 것들을 삭제 후 아래와 같이 합침
useQuery로 교체const [allRestaurants, setAllRestaurants] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(null);
useEffect(() => {
const getData = async () => {
try {
const data = await fetchAllRestaurants();
setAllRestaurants(data);
} catch (err) {
console.error("요청 실패:", err);
if (err instanceof Response) {
if (err.status === 404) {
setErrorMessage("요청하신 데이터를 찾을 수 없습니다. (404)");
} else {
setErrorMessage(`문제가 발생했습니다. (상태 코드: ${err.status})`);
}
} else {
setErrorMessage("서버와 연결할 수 없습니다. 주소를 확인해주세요.");
}
} finally {
setIsLoading(false);
}
};
getData();
}, []);
useEffect(() => {
const data = async () => {
try {
const all = await fetchAllRestaurants();
// console.log("백엔드 응답 : ", all);
setAllRestaurants(all);
} catch (err) {
console.error("에러 발생!", err);
} finally {
setIsLoading(false);
}
};
data();
}, []);
//컴포넌트가 처음 화면에 나타났을 때 딱 한 번만 실행
useEffect(() => {
const data = async () => {
try {
//아까 반환한 JSON 형식을 서버에 요청을 보내 데이터를 받음
const all = await fetchAllRestaurants();
//데이터를 받고 상태 변경 적용
setAllRestaurants(all);
// console.log("all :", all);
} catch (error) {
console.log(error);
}
};
data(); //비동기 함수를 실행
}, []);
useEffect가 모두 같은 fetchAllRestaurants()로 API 요청을 중복해서 보내고 있음const {
data: allRestaurants,
isLoading,
isError,
error,
} = useQuery({
queryKey: ["allRestaurants"],
queryFn: fetchAllRestaurants,
});
state 값을 모두 data 안에 넣음useQuery에서는 queryKey에 주로 사용했던 allRestaurants로 적용queryFn에서는 주로 호출헀던 API 함수 fetchAllRestaurants로 적용error은 원래 객체상태여서 isError는 에러 여부 판단하는 용도이다.error객체 안에 있는 속성 message로 무슨 오류인지 알려줌3️⃣ 추가로 아래 찜 버튼 클릭 시 찜목록에 추가하기 위한 상태도 useQuery로 변경
const [likedRestaurants, setLikedRestaurants] = useState([]);
useEffect(() => {
const fetchLikes = async () => {
try {
const data = await getUserLikes();
setLikedRestaurants(data.places);
} catch (err) {
console.error("찜 목록 불러오기 실패!", err);
}
};
fetchLikes();
}, []);
const {
data: likedRestaurants,
isLoading: isLikesLoading,
isError: isLikesError,
error: likesError,
} = useQuery({
queryKey: ["likedRestaurants"],
queryFn: getUserLikes,
select: (data) => data.places,
});
select 옵션은 queryFn으로 받아온 전체 응답 데이터에서 내가 원하는 데이터만 추출해서 가공할 수 있게 도와주는 기능💡 select 란?
{
userId: "u1",
places: [
{ id: "r1", title: "냉면집", ... },
...
]
}
userId는 필요 없고, places만 꺼내쓰고 싶다면 아래와 같이 작성select: (data) => data.places
useMutation은React Query에서 POST, DELETE, PUT 요청처럼 데이터를 서버에 보내는 작업- 여기에서는 찜 추가 및 삭제 요청을 서버로 보낼 때 사용
- 서버에 요청이 성공한 경우
invalidateQueries를 사용하여 지정한 쿼리를 자동으로 refetch(재요청) 함- 여기서는
likedRestaurants쿼리를 무효화해서 찜 목록을 최신 상태로 유지되도록 함
const handleLikeToggle = async (restaurant) => {
const isLiked = likedRestaurants.some((item) => item.id === restaurant.id);
try {
if (isLiked) {
await deleteUserLike(restaurant.id);
likedRestaurants((prev) =>
prev.filter((item) => item.id !== restaurant.id)
);
} else {
await postUserLike(restaurant);
likedRestaurants((prev) => [...prev, restaurant]);
}
} catch (err) {
console.error("찜 토글 실패:", err);
}
};
1️⃣ 먼저 찜 추가 함수 작성
const postLikeMutation = useMutation({
mutationFn: postUserLike,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
},
onError: (err) => {
console.error("찜 추가 실패: ", err);
},
});
mutationFn 에서는 실제 서버에 POST 요청을 보내는 함수 지정onSuccess) 찜 목록 데이터를 다시 불러오도록 하는 것queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
2️⃣ 찜 삭제 함수 작성
const deleteLikeMutation = useMutation({
mutationFn: deleteUserLike,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
},
onError: (err) => {
console.error("찜 삭제 실패: ", err);
},
});
mutationFn 에서 실제 서버에 DELETE 요청을 보내는 함수 지정onSuccess) 찜 목록에서 맛집이 삭제가 되고 찜 목록을 다시 불러오는 것queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
3️⃣ 하트 버튼 클릭했을 때 호출되는 함수 작성
const handleLikeToggle = async (restaurant) => {
const isLiked = likedRestaurants.some((item) => item.id === restaurant.id);
if (isLiked) {
deleteLikeMutation.mutate(restaurant.id);
} else {
postLikeMutation.mutate(restaurant);
}
};
isLiked로 찜했던 맛집인지 확인 const isLiked = likedRestaurants.some((item) => item.id === restaurant.id);
deleteLikeMutation.mutate(restaurant.id);
postLikedMutation.mutate(restaurant);
useEffect(() => {
// 브라우저에서 현재 사용자 위치 요청
navigator.geolocation.getCurrentPosition(
(position) => {
// 위도, 경도 받아오기
const { latitude: userLat, longitude: userLon } = position.coords;
// 사용자의 현재 위치를 기준으로 맛집 목록을 거리순으로 정렬
if (
allRestaurants &&
Array.isArray(allRestaurants.places) &&
allRestaurants.places.length > 0
) {
const sorted = sortPlacesByDistance(
allRestaurants.places,
userLat,
userLon
);
// 정렬된 데이터를 상태에 저장(장소는 거리순으로 변경됨)
allRestaurants({ ...allRestaurants, places: sorted });
}
},
(error) => {
console.error("위치 정보를 가져오는 데 실패했습니다.", error);
}
);
//거리 순으로 상태가 변경될 때마다 렌더링
}, [allRestaurants]);
const [sortedRestaurants, setSortedRestaurants] = useState([]);
useEffect(() => {
// 브라우저에서 현재 사용자 위치 요청
navigator.geolocation.getCurrentPosition(
(position) => {
// 위도, 경도 받아오기
const { latitude: userLat, longitude: userLon } = position.coords;
// 사용자의 현재 위치를 기준으로 맛집 목록을 거리순으로 정렬
if (
allRestaurants &&
Array.isArray(allRestaurants.places) &&
allRestaurants.places.length > 0
) {
const sorted = sortPlacesByDistance(
allRestaurants.places,
userLat,
userLon
);
// 정렬된 데이터를 상태에 저장(장소는 거리순으로 변경됨)
setSortedRestaurants({ ...allRestaurants, places: sorted });
}
},
(error) => {
console.error("위치 정보를 가져오는 데 실패했습니다.", error);
}
);
//거리 순으로 상태가 변경될 때마다 렌더링
}, [allRestaurants]);
1️⃣ 새로운 상태 추가 및 생성
allRestaurants는 useQuery에서 받아온 데이터이므로 setState 함수가 아님allRestaurants({ ...allRestaurants, places: sorted });
위 코드의 allRestaurants 부분을 새로 선언한 상태 함수인 setSortedRestaurants로 변경
2️⃣ return에 있는 반환 코드 수정
<List
allRestaurants={sortedRestaurants || allRestaurants}
likedRestaurants={likedRestaurants}
onToggleLike={handleLikeToggle}
/>
<List /> 렌더링 시 정렬된 데이터 넘김import "./App.css";
import Header from "./components/Header";
import LikeCardList from "./components/LikeCardList";
import List from "./components/List";
import SkeletonList from "./components/SkeletonList";
import {
fetchAllRestaurants,
getUserLikes,
postUserLike,
deleteUserLike,
} from "./api/restaurantAPI";
import { sortPlacesByDistance } from "./utils/loc";
import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useState } from "react";
function App() {
const [sortedRestaurants, setSortedRestaurants] = useState([]);
const queryClient = useQueryClient();
const {
data: allRestaurants,
isLoading,
isError,
error,
} = useQuery({
queryKey: ["allRestaurants"],
queryFn: fetchAllRestaurants,
});
const {
data: likedRestaurants,
isLoading: isLikesLoading,
isError: isLikesError,
error: likesError,
} = useQuery({
queryKey: ["likedRestaurants"],
queryFn: getUserLikes,
select: (data) => data.places,
});
console.log("likedRestaurants : ", likedRestaurants);
useEffect(() => {
// 브라우저에서 현재 사용자 위치 요청
navigator.geolocation.getCurrentPosition(
(position) => {
// 위도, 경도 받아오기
const { latitude: userLat, longitude: userLon } = position.coords;
// 사용자의 현재 위치를 기준으로 맛집 목록을 거리순으로 정렬
if (
allRestaurants &&
Array.isArray(allRestaurants.places) &&
allRestaurants.places.length > 0
) {
const sorted = sortPlacesByDistance(
allRestaurants.places,
userLat,
userLon
);
// 정렬된 데이터를 상태에 저장(장소는 거리순으로 변경됨)
setSortedRestaurants({ ...allRestaurants, places: sorted });
}
},
(error) => {
console.error("위치 정보를 가져오는 데 실패했습니다.", error);
}
);
//거리 순으로 상태가 변경될 때마다 렌더링
}, [allRestaurants]);
const postLikeMutation = useMutation({
mutationFn: postUserLike,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
},
onError: (err) => {
console.error("찜 추가 실패: ", err);
},
});
const deleteLikeMutation = useMutation({
mutationFn: deleteUserLike,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["likedRestaurants"] });
},
onError: (err) => {
console.error("찜 삭제 실패: ", err);
},
});
const handleLikeToggle = async (restaurant) => {
const isLiked = likedRestaurants.some((item) => item.id === restaurant.id);
if (isLiked) {
deleteLikeMutation.mutate(restaurant.id);
} else {
postLikeMutation.mutate(restaurant);
}
};
return (
<div className="min-h-screen bg-[#121212] text-gray-200">
<Header />
<main className="max-w-6xl mx-auto px-4 py-8">
<section className="mb-8 bg-[#1e1e1e] p-8 rounded-xl border-[#333] shadow-xl">
<h3 className="text-xl font-semibold mb-4 text-center text-white">
❤️ 찜한 맛집
</h3>
{isLikesError && (
<div className="text-red-400 text-center my-4 font-medium">
{likesError.message}
</div>
)}
{isLikesLoading ? (
<div>찜 목록 불러오는 중...</div>
) : (
<LikeCardList
likedRestaurants={likedRestaurants}
onToggleLike={handleLikeToggle}
/>
)}
</section>
<section className="bg-[#1e1e1e] p-8 rounded-xl shadow-xl">
<h2 className="text-xl font-semibold mb-4 text-center text-white">
📍전체 맛집 목록
</h2>
{isError && (
<div className="text-red-400 text-center my-4 font-medium">
{error.message}
</div>
)}
{isLoading ? (
<SkeletonList />
) : (
<List
allRestaurants={sortedRestaurants || allRestaurants}
likedRestaurants={likedRestaurants}
onToggleLike={handleLikeToggle}
/>
)}
</section>
</main>
</div>
);
}
export default App;