0. 들어가며
리액트 마지막 수강날이라 오늘은 조별로 그룹 미니 프로젝트를 진행했다.
각자 REST API 를 활용하는 컴포넌트를 만들어서 라우팅시켜 묶어서 페이지를 완성하는 미니 프로젝트였다.
1. 뭘 만들지? 그리고 잠깐의 삽질
postman의 public api와 다른 오픈 api들을 모아놓은 사이트를 강사님께서 제공해주셔서 살펴봤는데,
딱히 땡기는 주제가 없어서 구글링하기 시작했다.
그러다 우연히 찾게된 오픈 api 모음 github 발견..
https://github.com/dl0312/open-apis-korea
GitHub - dl0312/open-apis-korea: 🇰🇷 한국어 사용자를 위한 서비스에 사용하기 위한 오픈 API 모음
🇰🇷 한국어 사용자를 위한 서비스에 사용하기 위한 오픈 API 모음. Contribute to dl0312/open-apis-korea development by creating an account on GitHub.
github.com
오! 카테고리도 다양하고 여기서 만들면 되겠구나!
.. 했는데 대부분 지금은 제공이 안되는 api들이 많았다. (하고싶은 것들 중에서)
그러다 우연히 암호화폐 카테고리를 보고
대한민국 암호화폐 거래소인 upbit의 오픈 api를 사용하여 간단한 코인 가격 확인 사이트를 만들기로 했다.
upbit의 오픈 api에 대한 공식문서는 쉽게 찾을 수 있었다.
https://docs.upbit.com/docs/upbit-quotation-restful-api
업비트 개발자 센터
docs.upbit.com
위 문서를 토대로
나중에 팀원분께서 거의 10명분의 컴포넌트를 빠르게 병합해야하다보니
jsx파일 하나에 다 때려박기로 했다.
그래서..
import React, { useState, useEffect } from "react";
import useWebSocket from "react-use-websocket";
const CryptoPriceList = () => {
const [markets, setMarkets] = useState([]);
const [prices, setPrices] = useState({});
const [hoveredItem, setHoveredItem] = useState(null); // 현재 호버 중인 항목
const [error, setError] = useState(null);
const socketUrl = "wss://api.upbit.com/websocket/v1";
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
onError: (err) => setError(err),
shouldReconnect: () => true,
});
useEffect(() => {
const fetchMarkets = async () => {
try {
const response = await fetch("https://api.upbit.com/v1/market/all");
const data = await response.json();
const krwMarkets = data.filter((market) =>
market.market.startsWith("KRW-")
);
setMarkets(krwMarkets);
} catch (err) {
console.error("Failed to fetch markets:", err);
setError(err);
}
};
fetchMarkets();
}, []);
useEffect(() => {
if (markets.length > 0) {
sendMessage(
JSON.stringify([
{ ticket: "UNIQUE_TICKET" },
{ type: "ticker", codes: markets.map((market) => market.market) },
])
);
}
}, [markets, sendMessage]);
useEffect(() => {
if (lastMessage !== null) {
const processMessage = async () => {
try {
const dataText = await lastMessage.data.text();
const data = JSON.parse(dataText);
if (data.type === "ticker") {
setPrices((prevPrices) => ({
...prevPrices,
[data.code]: {
price: data.trade_price,
changeRate: data.signed_change_rate,
},
}));
}
} catch (err) {
console.error("Message Parsing Error:", err);
setError(err);
}
};
processMessage();
}
}, [lastMessage]);
if (error) {
return <div>에러 발생: {error.message}</div>;
}
if (readyState !== 1) {
return <div>WebSocket 연결 중...</div>;
}
return (
<div>
<style>
{`
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.crypto-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 20px;
font-family: Arial, sans-serif;
padding: 20px;
}
.crypto-box {
width: 60%;
max-width: 700px;
height: 700px;
border: 1px solid #ccc;
border-radius: 8px;
overflow-y: auto;
padding: 10px;
background-color: #f9f9f9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.crypto-list {
list-style: none;
padding: 0;
margin: 0;
}
.crypto-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
font-size: 14px;
transition: background-color 0.3s ease;
}
.crypto-item:last-child {
border-bottom: none;
}
.crypto-item:hover {
background-color: #f0f8ff;
}
.crypto-item span {
flex: 1;
text-align: center;
align-items: center;
display: flex;
justify-content: center;
}
.crypto-name {
font-weight: bold;
}
.crypto-price {
color: #333;
}
.crypto-change {
display: flex;
align-items: center;
gap: 5px;
}
.crypto-change.positive {
color: red;
}
.crypto-change.negative {
color: blue;
}
.info-container {
width: 30%;
max-width: 300px;
height: 700px;
display: flex;
flex-direction: column;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #ffffff;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.chart-box {
flex: 2;
padding: 15px;
border-bottom: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
background-color: #f9f9f9;
}
.info-box {
flex: 1;
padding: 15px;
display: flex;
justify-content: center;
align-items: center;
background-color: #ffffff;
}
.info-title {
font-weight: bold;
margin-bottom: 10px;
}
.info-content {
font-family: monospace;
color: #444;
}
.info-placeholder {
text-align: center;
font-size: 16px;
color: #777;
}
`}
</style>
<div className="crypto-container">
<div className="crypto-box">
<ul className="crypto-list">
{markets.map((market) => {
const { market: code, korean_name } = market;
const priceData = prices[code] || {};
const isPositive =
priceData.changeRate !== undefined && priceData.changeRate > 0;
return (
<li
key={code}
className="crypto-item"
onMouseEnter={() =>
setHoveredItem({ code, korean_name, priceData })
}
onMouseLeave={() => setHoveredItem(null)}
>
<span className="crypto-name">
{korean_name} ({code.replace("KRW-", "")})
</span>
<span className="crypto-price">
{priceData.price
? `${priceData.price.toLocaleString()}원`
: "Loading..."}
</span>
<span
className={`crypto-change ${
isPositive ? "positive" : "negative"
}`}
>
{isPositive ? "▲" : "▼"}
{priceData.changeRate !== undefined
? `(${(priceData.changeRate * 100).toFixed(2)}%)`
: ""}
</span>
</li>
);
})}
</ul>
</div>
<div className="info-container">
<div className="chart-box">
<p>여기에 캔들 차트를 추가할 수 있습니다.</p>
</div>
<div className="info-box">
{hoveredItem ? (
<div>
<div className="info-title">API 설명</div>
<div className="info-content">
<strong>Market:</strong> {hoveredItem.code} <br />
<strong>Name:</strong> {hoveredItem.korean_name} <br />
<strong>Price:</strong>{" "}
{hoveredItem.priceData.price || "Loading..."} <br />
<strong>Change Rate:</strong>{" "}
{hoveredItem.priceData.changeRate
? `${(hoveredItem.priceData.changeRate * 100).toFixed(2)}%`
: "Loading..."}
<br />
<strong>Request:</strong>{" "}
<code>GET /v1/ticker?markets={hoveredItem.code}</code>
<br />
<strong>Response:</strong> JSON 데이터에서 `trade_price` 및
`signed_change_rate` 활용.
</div>
</div>
) : (
<div className="info-placeholder">
마우스를 항목에 올려 설명을 확인하세요.
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default CryptoPriceList;
라는 코드가 나오게 되었고
이렇게 결과물이 나오긴했다.
문제는 두가지였는데
1. 아무래도 코인관련 데이터들은 실시간으로 표시되어야 의미가 있기 때문에 webSocket 을 사용했다.
거의 다 만들때쯤 보니까 axios만 사용하라고 하셨더라.
2. 만들어놓은게 아까워서 캔들차트는 그래도 지난 시간에 대한 데이터이기 때문에
캔들차트를 axios로 구성하고, 실시간 코인 차트는 그대로 두자! 하고 캔들차트 작업을 시작했는데
로컬환경에 대해서 데이터를 보내주지 않아, cors 에러에 걸려서..
배포없이 로컬로만 완성하는 초 미니프로젝트였기 때문에 다른 주제를 찾기로 했다.
2. 댕댕이를 생성하자
강사님이 제공해주신 오픈 api 중에서
https://www.postman.com/cs-demo/public-rest-apis/request/ra0pm82/dog-breeds
Dog Breeds | Public REST APIs
Start sending API requests with the Dog Breeds public request from Public REST APIs on the Postman API Network.
www.postman.com
가 눈에 들어왔다.
위 오픈 api는 개 품종 리스트 조회, 개 사진 랜덤 조회, 개 품종별 랜덤 이미지 조회를 제공하고 있었는데
이 주제를 선택할 당시 앞에서 삽질로 시간을 대부분 날려버렸기 때문에 1시간정도 밖에 남지 않아
강아지를 키우는 본인은 본능적으로 이 주제를 선택하게 되었다.
import React, { useState, useEffect } from "react";
import axios from "axios";
const RandomDog = () => {
const [dogImage, setDogImage] = useState(""); // 강아지 사진 URL 상태 관리
const [breeds, setBreeds] = useState({}); // 강아지 품종 리스트 상태 관리 (알파벳별로 그룹화)
const [selectedLetter, setSelectedLetter] = useState(""); // 선택된 알파벳
const [loadingImage, setLoadingImage] = useState(false); // 이미지 로딩 상태 관리
const [loadingBreeds, setLoadingBreeds] = useState(false); // 품종 로딩 상태 관리
// 모든 품종 리스트 가져오기
useEffect(() => {
const fetchBreeds = async () => {
setLoadingBreeds(true);
try {
const response = await axios.get("https://dog.ceo/api/breeds/list/all");
const breedNames = Object.keys(response.data.message);
// 품종을 알파벳 기준으로 그룹화
const groupedBreeds = breedNames.reduce((acc, breed) => {
const letter = breed[0].toUpperCase();
if (!acc[letter]) acc[letter] = [];
acc[letter].push(breed);
return acc;
}, {});
setBreeds(groupedBreeds);
} catch (error) {
console.error("댕댕이 불러오기 실패:", error);
} finally {
setLoadingBreeds(false);
}
};
fetchBreeds();
}, [setLoadingBreeds]); // setLoadingBreeds를 종속성 배열에 추가
// 랜덤 강아지 사진 가져오기 (품종 선택 시)
const fetchBreedImage = async (breed) => {
setLoadingImage(true);
try {
const response = await axios.get(
`https://dog.ceo/api/breed/${breed}/images/random`
);
setDogImage(response.data.message);
} catch (error) {
console.error(`선택 품종 랜덤 이미지 불러오기 실패 ${breed}:`, error);
} finally {
setLoadingImage(false);
}
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f7f7f7",
fontFamily: "Arial, sans-serif",
padding: "20px",
}}
>
{/* 제목 */}
<h1 style={{ color: "#333", marginBottom: "20px" }}>
🐕 랜덤 댕댕이 생성기 🐕
</h1>
{/* 이미지와 품종 리스트 */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
}}
>
{/* 이미지 박스 */}
<div
style={{
width: "350px",
height: "350px",
border: "2px dashed #ccc",
borderRadius: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginRight: "20px",
backgroundColor: "#fff",
color: "#888",
}}
>
{loadingImage ? (
<p>댕댕이 찾는 중..</p>
) : dogImage ? (
<img
src={dogImage}
alt="Random Dog"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "10px",
}}
/>
) : (
<p style={{ fontSize: "16px", textAlign: "center" }}>
강아지 품종을 선택해주세요 <br /> 랜덤으로 사진을 가져와요
</p>
)}
</div>
{/* 품종 리스트 */}
<div
style={{
display: "flex",
flexDirection: "row",
width: "450px",
height: "350px",
backgroundColor: "#fff",
border: "1px solid #ccc",
borderRadius: "10px",
boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
}}
>
{/* 알파벳 리스트 */}
<div
style={{
width: "50px",
borderRight: "1px solid #ccc",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "start",
paddingTop: "10px",
backgroundColor: "#f9f9f9",
overflowY: "scroll",
maxHeight: "100%",
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
<style>
{`
div::-webkit-scrollbar {
display: none;
}
`}
</style>
{Object.keys(breeds).map((letter) => (
<div
key={letter}
style={{
padding: "10px",
cursor: "pointer",
fontWeight: letter === selectedLetter ? "bold" : "normal",
color: letter === selectedLetter ? "#333" : "#888",
transition: "color 0.2s",
}}
onClick={() => setSelectedLetter(letter)}
>
{letter}
</div>
))}
</div>
{/* 선택된 알파벳의 품종 리스트 */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "10px",
backgroundColor: "#fff",
}}
>
<h3
style={{
textAlign: "center",
color: "#555",
marginBottom: "10px",
}}
>
{selectedLetter
? `${selectedLetter}로 시작하는 품종`
: "알파벳을 선택해주세요"}
</h3>
{loadingBreeds ? (
<p style={{ textAlign: "center", color: "#888" }}>로딩 중...</p>
) : selectedLetter && breeds[selectedLetter] ? (
<ul
style={{
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{breeds[selectedLetter].map((breed) => (
<li
key={breed}
style={{
marginBottom: "10px",
padding: "10px",
borderRadius: "5px",
backgroundColor: "#f1f1f1",
color: "#333",
cursor: "pointer",
textAlign: "center",
transition: "background-color 0.2s",
}}
onClick={() => fetchBreedImage(breed)}
onMouseEnter={(e) => {
e.target.style.backgroundColor = "#e0e0e0";
}}
onMouseLeave={(e) => {
e.target.style.backgroundColor = "#f1f1f1";
}}
>
{breed}
</li>
))}
</ul>
) : (
<p style={{ textAlign: "center", color: "#888" }}>품종 없음</p>
)}
</div>
</div>
</div>
{/* Info Box */}
<div
style={{
width: "780px",
height: "300px", // 고정 세로 크기
backgroundColor: "#fff",
border: "1px solid #ccc",
borderRadius: "10px",
padding: "20px",
boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
overflowY: "scroll", // 스크롤 활성화
scrollbarWidth: "none", // 스크롤바 숨기기 (Firefox)
msOverflowStyle: "none", // 스크롤바 숨기기 (IE/Edge)
marginBottom: "60px",
}}
>
{/* 스크롤바 숨기기 (Webkit 기반 브라우저) */}
<style>
{`
div::-webkit-scrollbar {
display: none;
}
`}
</style>
<h2
style={{ textAlign: "center", color: "#555", marginBottom: "10px" }}
>
REST API 정보
</h2>
<p>
<strong>API 1:</strong> https://dog.ceo/api/breeds/list/all
</p>
<ul>
<li>요청 방식: GET</li>
<li>응답 구조:</li>
<pre>{`{
"message": {
"affenpinscher": [],
"african": [],
"airedale": [],
...
},
"status": "success"
}`}</pre>
</ul>
<p>
<strong>API 2:</strong> https://dog.ceo/api/breed/{"{"}breed{"}"}
/images/random
</p>
<ul>
<li>요청 방식: GET</li>
<li>응답 구조:</li>
<pre>{`{
"message": "https://images.dog.ceo/breeds/hound-afghan/n02088094_1003.jpg",
"status": "success"
}`}</pre>
</ul>
</div>
</div>
);
};
export default RandomDog;
이 역시 팀원분의 빠른 컴포넌트 병합을 위해 jsx 파일 하나에 요소들을 때려박았고,
css같은 경우에는 gpt의 도움을 좀 받았다.........
아래는 결과물!
문제점은 다음과 같다
팀원분께서 병합하시고 나니까 REST API 정보 쪽의 내용 부분 레이아웃이 깨졌다.
css가 겹쳐서 깨지는 문제는 아닐테고, 아마 반응형을 고려하지 않은 죄다 px로 설정해둔 css문제일 것..
원래 레이아웃을 잘 모르는 사람한테는 큰 이상없었지만 나는 좀 불편했다.
3. 마치며
react 학습을 모두 마치지 못해서 미흡한점이 많고, 실습이 컴포넌트 하나씩 만들어서 팀원들의 결과물들을 합치는 것이었기 때문에 프로젝트 단위의 react 경험은 아직 못해본 점이 아쉽다.
내일부터는 백엔드 수업이 시작되지만, 따로 리액트 인프런 강의를 수강중이기 때문에 얼른 마저 수강하고 좀 더 공부해봐야겠다.
그래도 뭐 별거 한건 없지만
확실히 아직까지는 vue가 더 쉬운 것 같다!
'Experience > LG CNS AM Inspire Camp 1기' 카테고리의 다른 글
[LG CNS AM Inspire Camp] 6. React의 Ref와 상태 관리 최적화 (0) | 2025.02.10 |
---|---|
[LG CNS AM Inspire Camp] 5. 이벤트 핸들링 및 컴포넌트 상태 관리 (0) | 2025.02.10 |
[LG CNS AM Inspire Camp] 3. DOM이란 무엇인가? (0) | 2025.01.02 |
[LG CNS AM Inspire Camp] 2. JavaScript와 Browser의 동작원리 (1) | 2025.01.02 |
[LG CNS AM Inspire Camp] 1. IntelliJ는 잠시.. VSCode에 적응해보기 (1) | 2024.12.31 |