카카오 API를 사용한 지도 연결, zustand를 사용한 코드 tanstackQuery를 사용하여 리펙토링
상세페이지의 지도를 연결하고
맨 처음 zustand를 사용해서 api를 연결하고 데이터를 저장했다.
tanstackQuery로 댓글 작성 기능을 만들고 zustand로 데이터를 저장할 필요없이 tanstackQuery로 관리하는 것이 더 좋을 것 같다는 피드백을 받았다. 기능을 개발하고 리펙토링하는데 많은 에러들을 겪게 되었다...!
// DetailPage.jsx
const { data, fetchData, error } = useKopisStore((state) => ({
data: state.data,
fetchData: state.fetchData,
error: state.error,
}));
const { id } = useParams();
useEffect(() => {
fetchData(id);
}, [fetchData, id]);
if (error) {
return <div className="text-red-500">에러 발생: {error}</div>;
}
if (!data) {
return <div className="text-gray-500">Loading...</div>;
}
// DetailMap.jsx
import React, { useEffect, useRef } from "react";
import useKopisStore from "../../zustand/useKopisStore";
const DetailMap = () => {
const mapRef = useRef(null);
// 공연 장소 가져오기
const { fetchMapData, mapData, data } = useKopisStore((state) => ({
fetchMapData: state.fetchMapData,
mapData: state.mapData,
data: state.data,
}));
// 상세페이지 정보에서 공연장소id 뽑아내기
const placeId = data?.mt10id; // data가 존재하는지 확인
// 공연 장소 첫화면에 불러오기
useEffect(() => {
if (placeId) {
fetchMapData(placeId);
}
}, [fetchMapData, placeId]);
useEffect(() => {
if (
window.kakao &&
window.kakao.maps &&
mapData?.la &&
mapData?.lo // mapData와 좌표가 유효한지 확인
) {
const mapContainer = mapRef.current;
const mapOption = {
center: new window.kakao.maps.LatLng(mapData.la, mapData.lo), // 지도의 중심 좌표
level: 3, // 지도의 확대 레벨
};
// 지도 생성
const map = new window.kakao.maps.Map(mapContainer, mapOption);
// 마커 위치 설정
const markerPosition = new window.kakao.maps.LatLng(
mapData.la,
mapData.lo
);
// 마커 생성
const marker = new window.kakao.maps.Marker({
position: markerPosition,
});
// 마커를 지도에 표시
marker.setMap(map);
// 인포윈도우 설정
const infowindow = new window.kakao.maps.InfoWindow({
content: '<div style="padding:5px;">여기에 위치</div>', // 인포윈도우 내용
});
// 마커 클릭 시 인포윈도우 표시
window.kakao.maps.event.addListener(marker, "click", () => {
infowindow.open(map, marker);
});
} else {
console.error("Kakao maps SDK not loaded or mapData not available");
}
}, [mapData]); // mapData가 변경될 때마다 실행
// mapData가 null 또는 undefined인지 확인하여 정보가 없을 때 처리
if (!mapData) {
return (
<div>
<h4 className="font-extrabold text-3xl mb-8">장소 정보가 없습니다.</h4>
</div>
);
}
return (
<>
<h4 className="font-extrabold text-3xl mb-8">장소</h4>
{mapData.adres ? (
<p className="mb-8">{mapData.adres}</p>
) : (
<p>주소 정보가 없습니다.</p>
)}
{mapData.telno ? (
<div>
<p className="font-extrabold text-3xl mb-8">문의</p>
<p className="mb-8">{mapData.telno}</p>
</div>
) : (
<p>문의처 정보가 없습니다.</p>
)}
<div ref={mapRef} style={{ width: "100%", height: "400px" }}>
{/* 지도 표시 */}
</div>
</>
);
};
export default DetailMap;
// useKopisStore.js
import { create } from "zustand";
import { fetchDetailData, fetchMapData } from "../api/detailApi";
const useKopisStore = create((set, get) => ({
data: null,
mapData: null,
error: null,
// 공연 상세 정보 가져오는 함수
fetchData: async (id) => {
try {
const result = await fetchDetailData(id);
if (result && result.dbs && result.dbs.db) {
set({ data: result.dbs.db, error: null });
} else {
set({ error: "No data found", data: null });
}
} catch (error) {
set({ error: `Error fetching details: ${error.message}`, data: null });
}
},
// 공연장소 데이터를 가져와 상태에 저장
fetchMapData: async (id) => {
try {
const result = await fetchMapData(id); // API 호출
console.log(result);
if (result && result.dbs && result.dbs.db) {
set({ mapData: result.dbs.db, error: null }); // 상태에 저장
console.log(get().mapData);
} else {
set({ error: "No map data found", mapData: null });
}
} catch (error) {
set({
error: `Error fetching map data: ${error.message}`,
mapData: null,
});
}
},
}));
export default useKopisStore;
// detailApi.js
import axios from "axios";
import { parseXMLToJSON } from "../utils/utils";
const apiKey = import.meta.env.VITE_KOPIS_KEY;
// 공연 상세 데이터를 가져오는 함수
export const fetchDetailData = async (id) => {
const url = `http://kopis.or.kr/openApi/restful/pblprfr/${id}?service=${apiKey}`;
try {
const response = await axios.get(url);
const jsonData = parseXMLToJSON(response.data);
console.log(jsonData); // 변환된 JSON 데이터 콘솔 출력
return jsonData;
} catch (error) {
console.error("Error fetching performance details:", error);
throw new Error("데이터를 불러오는 중 오류가 발생했습니다.");
}
};
// 공연 장소 데이터를 가져오는 함수
export const fetchMapData = async (placeId) => {
const mapUrl = `http://www.kopis.or.kr/openApi/restful/prfplc/${placeId}?service=${apiKey}`;
try {
const response = await axios.get(mapUrl);
const jsonData = parseXMLToJSON(response.data);
return jsonData;
} catch (error) {
console.error("Error fetching performance locations:", error);
throw new Error("데이터를 불러오는 중 오류가 발생했습니다.");
}
};
// 상세페이지 댓글 작성 기능
const jsonUrl = "http://localhost:5000/comments";
const commentApi = axios.create({ baseURL: jsonUrl });
export const detailAddComment = async (newComment) => {
const { data } = await commentApi.post("/", newComment);
return data;
};
export const detailGetComment = async () => {
const { data } = await commentApi.get("/");
return data;
};
맨 처음에는 위와 같이 detailApi.js에서 axios를 사용해 정보를 불러오고 이것을 zustand에서 전역에서 관리할 수 있도록 데이터를 저장하여 해당 데이터가 필요한 페이지에서 useStore로 데이터를 꺼내썼다. 이렇게 사용했을 때 useEffect, useState를 사용해 데이터를 불러오고, 로딩중일 때와 에러가 났을 때의 상황을 복잡한 코드로 직접 설정해주어야 했다. 튜터님께서 오늘 전체적인 코드를 봐주셨는데 tanstackQuery를 사용할 수 있는 지금 zustand를 사용하여 복잡한 로직을 사용해 데이터를 관리하는 것보다 tanstackQuery를 사용하여 리펙토링 하는 것을 추천해주셨다.
api를 불러오는 로직은 detailApi.js에서 사용하고 데이터 저장관리만 useKopisStore.js에서 관리하고 있었기 때문에 useQuery로 zustand를 대체해주었다. 이미 상세페이지에서 댓글을 tanstackQuery로 관리하며
main.js에 아래 provider가 로드되어있어 useQuery문만 작성해주었다.
// main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import 'the-new-css-reset/css/reset.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient();
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<StrictMode>
<App />
</StrictMode>
</QueryClientProvider>,
)
useParams를 이용해 id를 가져와 api에 id를 넣어주는 것은 동일하기 때문에 그대로 사용하였고 queryKey와 queryFn을 지정해 데이터를 불러왔다.
const { id } = useParams();
// 공연 상세 정보 불러오기
const {
data,
isLoading: detailLoading,
isError: detailError,
} = useQuery({
queryKey: ["performanceDetail", id],
queryFn: () => fetchDetailData(id),
});
const detailData = data?.dbs?.db;
if (detailLoading) {
return <div>Loading...</div>;
}
if (detailError) {
return <div>Error</div>;
}
그리고 이미 query에 내장되어있는 isLoading과 isError를 지정해 로딩중일 때와 에러가 났을 때 처리될 수 있도록 로직을 작성해주었다.
카카오 api를 사용해 지도를 불러오는 경험이 처음이라 연결하는데 꽤 많은 시간이 걸렸다. script를 추가할 때 바로 api key를 집어넣으면 편했겠지만 .env에 key를 저장하고 불러와 보안성을 강화하려고 노력했다.
먼저 .env에 내 api key를 저장해주었다. 나는 vite로 react를 만들어주었기 때문에 VITE_KAKAOMAP_KEY = ~~ 로 지정해주었다.
그리고 index.html에서 script를 지정해주었다.
// DetailMap.jsx
<script type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%VITE_KAKAOMAP_KEY%&libraries=services,clusterer"></script>
맨 처음에는 카카오api docs에 나와있는 방법으로 지도를 불러왔다.
kopis 공연장소 api를 불러오고 데이터를 저장하는 것도 zustand로 진행하여 아래와 같은 로직을 작성했었다.
import React, { useEffect, useRef } from "react";
import useKopisStore from "../../zustand/useKopisStore";
const DetailMap = () => {
const mapRef = useRef(null);
// 공연 장소 가져오기
const { fetchMapData, mapData, data } = useKopisStore((state) => ({
fetchMapData: state.fetchMapData,
mapData: state.mapData,
data: state.data,
}));
// 상세페이지 정보에서 공연장소id 뽑아내기
const placeId = data?.mt10id; // data가 존재하는지 확인
// 공연 장소 첫화면에 불러오기
useEffect(() => {
if (placeId) {
fetchMapData(placeId);
}
}, [fetchMapData, placeId]);
useEffect(() => {
if (
window.kakao &&
window.kakao.maps &&
mapData?.la &&
mapData?.lo // mapData와 좌표가 유효한지 확인
) {
const mapContainer = mapRef.current;
const mapOption = {
center: new window.kakao.maps.LatLng(mapData.la, mapData.lo), // 지도의 중심 좌표
level: 3, // 지도의 확대 레벨
};
// 지도 생성
const map = new window.kakao.maps.Map(mapContainer, mapOption);
// 마커 위치 설정
const markerPosition = new window.kakao.maps.LatLng(
mapData.la,
mapData.lo
);
// 마커 생성
const marker = new window.kakao.maps.Marker({
position: markerPosition,
});
// 마커를 지도에 표시
marker.setMap(map);
// 인포윈도우 설정
const infowindow = new window.kakao.maps.InfoWindow({
content: '<div style="padding:5px;">여기에 위치</div>', // 인포윈도우 내용
});
// 마커 클릭 시 인포윈도우 표시
window.kakao.maps.event.addListener(marker, "click", () => {
infowindow.open(map, marker);
});
} else {
console.error("Kakao maps SDK not loaded or mapData not available");
}
}, [mapData]); // mapData가 변경될 때마다 실행
// mapData가 null 또는 undefined인지 확인하여 정보가 없을 때 처리
if (!mapData) {
return (
<div>
<h4 className="font-extrabold text-3xl mb-8">장소 정보가 없습니다.</h4>
</div>
);
}
return (
<>
<h4 className="font-extrabold text-3xl mb-8">장소</h4>
{mapData.adres ? (
<p className="mb-8">{mapData.adres}</p>
) : (
<p>주소 정보가 없습니다.</p>
)}
{mapData.telno ? (
<div>
<p className="font-extrabold text-3xl mb-8">문의</p>
<p className="mb-8">{mapData.telno}</p>
</div>
) : (
<p>문의처 정보가 없습니다.</p>
)}
<div ref={mapRef} style={{ width: "100%", height: "400px" }}>
{/* 지도 표시 */}
</div>
</>
);
};
export default DetailMap;
// useKopisStore.js
// 공연장소 데이터를 가져와 상태에 저장
fetchMapData: async (id) => {
try {
const result = await fetchMapData(id); // API 호출
console.log(result);
if (result && result.dbs && result.dbs.db) {
set({ mapData: result.dbs.db, error: null }); // 상태에 저장
console.log(get().mapData);
} else {
set({ error: "No map data found", mapData: null });
}
} catch (error) {
set({
error: `Error fetching map data: ${error.message}`,
mapData: null,
});
}
},
}));
// detailApi.js
export const fetchMapData = async (placeId) => {
const mapUrl = `http://www.kopis.or.kr/openApi/restful/prfplc/${placeId}?service=${apiKey}`;
try {
const response = await axios.get(mapUrl);
const jsonData = parseXMLToJSON(response.data);
return jsonData;
} catch (error) {
console.error("Map Error", error);
throw new Error("데이터를 불러오는 중 오류가 발생했습니다.");
}
};
: api를 불러오는 부분은 그대로 남겨두고 데이터를 저장하는 부분을 tanstackQuery로 변경시켜주었다. map 컴포넌트 또한 detailPage안에 있는 부분이기 때문에 props로 상세페이지 api데이터정보에 있는 장소 아이디를 받기위해 detailData를 내려받았다.
똑같이 지도 정보를 저장해주고 코드의 가독성과 다양한 지도 사용을 위해 카카오 지도 sdk를 활용하여 코드를 리펙토링해주었다.
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Map, MapMarker, ZoomControl } from "react-kakao-maps-sdk";
import { fetchMapData } from "../../api/detailApi";
const DetailMap = ({ detailData }) => {
const placeId = detailData?.mt10id;
const { data, isLoading, isError } = useQuery({
queryKey: ["mapData", placeId],
queryFn: () => fetchMapData(placeId),
enabled: !!placeId,
});
const mapData = data?.dbs?.db;
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error</div>;
}
if (!mapData) {
return <div>장소 정보가 없습니다.</div>;
}
// 좌표값을 숫자로 변환
const lat = parseFloat(mapData.la);
const lng = parseFloat(mapData.lo);
return (
<div>
<h4 className="font-extrabold text-3xl mb-8">장소</h4>
<p className="mb-8">{mapData.adres || "주소 정보가 없습니다."}</p>
{mapData.telno && (
<div>
<h4 className="font-extrabold text-3xl mb-8">문의</h4>
<p className="mb-8">{mapData.telno}</p>
</div>
)}
{!mapData.telno && <p>문의처 정보가 없습니다.</p>}
<Map
center={{ lat, lng }} // 좌표값을 숫자로 변환하여 설정
style={{ width: "100%", height: "500px" }}
level={3}
>
<MapMarker position={{ lat, lng }} />
<ZoomControl />
</Map>
</div>
);
};
export default DetailMap;
먼저 detailApi.js에서 json-server와 연결될 수 있도록 axio를 사용하여 댓글을 불러오고 추가하고 삭제하는 로직을 작성해주었다.
// 상세페이지 댓글 작성 기능
const jsonUrl = "http://localhost:5000/comments";
const commentApi = axios.create({ baseURL: jsonUrl });
// 댓글 추가
export const detailAddComment = async (newComment) => {
const { data } = await commentApi.post("/", newComment);
return data;
};
// 특정 공연의 댓글 가져오기
export const detailGetComment = async (performanceId) => {
const { data } = await commentApi.get("/");
return data.filter((comment) => comment.performanceId === performanceId);
};
// 댓글 삭제
export const detailDeleteComment = async (id) => {
const { data } = await commentApi.delete(`/${id}`);
return data;
};
db.json
"comments": [
{
"id": "d160",
"email": "58d6b8a8-eddc-4088-a757-07c4022e854e",
"content": "호이호이",
"performanceId": "PF248202"
},
]
DetailComments.jsx
import React, { useState } from "react";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import useUserStore from "../../zustand/useUserStore";
import { v4 as uuidv4 } from "uuid";
import {
detailAddComment,
detailDeleteComment,
detailGetComment,
} from "../../api/detailApi";
const DetailComments = ({ id }) => {
const [comment, setComment] = useState("");
const queryClient = useQueryClient();
const { user } = useUserStore();
// 댓글 추가 mutation
const addMutation = useMutation({
mutationFn: (newComment) =>
detailAddComment({ ...newComment, performanceId: id }),
onSuccess: () => {
queryClient.invalidateQueries(["comments", id]);
setComment("");
},
});
// 댓글 삭제 mutation
const removeMutation = useMutation({
mutationFn: detailDeleteComment,
onSuccess: () => {
queryClient.invalidateQueries(["comments", id]);
},
});
// 댓글 가져오기 query
const {
data: comments = [], // 기본값을 빈 배열로 설정
isLoading,
isError,
} = useQuery({
queryKey: ["comments", id],
queryFn: () => detailGetComment(id),
});
if (isLoading) {
return <p>로딩중입니다.</p>;
}
if (isError) {
return <p>댓글을 가져오던 중 에러가 발생했습니다.</p>;
}
const handleSubmit = (e) => {
e.preventDefault();
if (comment.trim()) {
addMutation.mutate({
email: user?.email || uuidv4(), // 로그인한 사용자 이메일 사용 또는 유니크 ID
content: comment,
});
}
};
const handleDelete = (id) => {
removeMutation.mutate(id);
};
return (
<>
<div>
<h4 className="font-extrabold text-3xl mb-8">회원리뷰</h4>
{comments.length > 0 ? (
comments.map((comment) => (
<div
className="w-[750px] bg-slate-200 rounded mx-auto pb-4 mb-8 relative"
key={comment.id}
>
<p className="font-bold text-left pl-4 mb-4">{comment.email}</p>
<p>{comment.content}</p>
<button
onClick={() => handleDelete(comment.id)}
className="cursor-pointer w-[50px] h-[30px] bg-accent rounded absolute bottom-2 right-2 "
>
삭제
</button>
</div>
))
) : (
<p>리뷰가 없습니다.</p>
)}
</div>
<form onSubmit={handleSubmit} className="mt-20">
<h4 className="font-extrabold text-3xl mb-8">한줄평</h4>
<input
type="text"
onChange={(e) => setComment(e.target.value)}
value={comment}
placeholder="리뷰를 작성해주세요"
className="w-[700px] h-[100px] bg-slate-200 rounded"
/>
<button
type="submit"
className="cursor-pointer w-[50px] h-[100px] bg-primary rounded text-white"
>
등록
</button>
</form>
</>
);
};
export default DetailComments;
tanstackQuery의 mutation, mutate, queryClient.invalidateQueries를 활용해 추가되고 삭제된 데이터들이 바로 반영될 수 있도록 로직을 작성해주었다.
user부분이 아직 개발이 덜 된 상태라서 로그인했을 때에만 댓글을 작성할 수 있도록 하는 부분은 아직 작성하지 않았다.
mutationFn과, onSuccess를 활용하여 댓글이 추가되고 나서 성공할 시 바로 반영될 수 있게 queryClient.invalidateQueries로 설정해주었다.
맨 처음에 tailwindCss도 tanstackQuery도 context, useState, useEffect, zustand redux...까지 다양하고 많은 훅들에 쓰임새와 용도가 많이 혼란스럽고 사용하는 것이 쉽지 않았다. 실습을 계속해서 진행하고 팀원들과 소통하며 내 코드를 함께 이야기하는 과정에서 많이 발전을 하고 있음을 느끼고 있는 것 같다. 또한 404에러같은 에러도 겪고 CORS에러도 겪으면서 많은 난항들을 겪고 있는데 하나하나 해결할 때마다 조금씩 성장해가고 있음을 느낀다. 앞으로도 많은 오류들이 나를 기다리고 있겠지만 그럼에도 힘을 내서 열심히 해결해보려고 한다.....!