Loca!T 프로젝트
한국관광공사가 제공한 Tour-api를 이용한 전국의 축제 리스트를 지역별 지도로 확인
React, NodeJS로 개발하여, 반응형 SPA 개발
github 주소
1. 개요
1.1 프로젝트 요약
1. 일정
2023년 9월 1일 ~ 2023년 10월 17일 (47일)
2. 프로젝트 요약
- 한국관광공사가 제공한 Tour-api와 카카오 map-api를 이용한 전국의 축제 리스트를 지역별 지도로 확인
- 네이버 sms-api를 이용한 로그인 인증기능 구현
- 정규화를 이용한 검색 알고리즘 구현
- axios를 이용한 실시간 DB사용으로 장바구니 기능 구현
- python schedule을 이용한 데이터 수집 자동화 알고리즘 구현
- React-Hook을 이용한 상태관리
- redux를 이용한 전역 상태관리
- Bootstrap을 활용
3. 나의 역할
- 프로젝트 팀장으로 일정조율, git 관리
- react, nodeJS과 각종 라이브러리를 이용하여 지도기능, 로그인-인증기능, 장바구니 기능을 구현하였으며, Python으로 데이터 수집 자동화 알고리즘 구현
4. 느낀점/배운점
- 기획단계에서 개발에 필요한 문서(기획서, 화면정의서, 유스케이스 명세서, DB설계서, 개발표준 정의서, 기능정의서, URI정의서)를 작성하였으며, WBS를 통한 일정관리를 함
- 다양한 전공의 프로젝트 팀원들과 함께 팀장으로서 개발 방향을 설계하고, 기한내에 성공적으로 개발을 완수함.
5. 개발시 발생한 문제점/해결책
- 프로젝트 진행중 git에서 충돌이 발생하는것을 최대한 줄이기 위해, 독립적인 작업공간을 정확히 배분하고, 한개의 branch만 사용하고, 먼저 push한 사람에게 무조건 우선순위를 준다는 전략을 취했었다.
- 카카오 map-api를 이용할때, cors에러가 발생하였으나, cors모듈을 이용하여 해결
- 카카오 map-api를 이용할때, 주자장 데이터가 6만개 정도되어 클러스터러를 계산할때, 문제가 생겼었다. 최적화를 진행한 결과 매끄럽게 지도를 사용할 수 있었다.
- 카카오 map-api를 이용할때, 지도를 선언할때, 화면이 렌더링 될때마다 지도객체가 재선언되어 지도가 계속 생겨났다. 상태관리 기술중 하나인 useRef를 이용하여, 해당 지도객체는 바뀌어도 재렌더링을 실시하지 않았다.
- 카카오 map-api를 이용할때, 지도를 이동할때, 지도안의 요소가 계속 생겨 문제가 생겼다. uesEffect의 cleanup기능을 이용하여 해당 요소를 삭제했다.
- 카카오 map-api를 이용할때, 인포윈도우 기능을 이용하였는데, 인포윈도우 안의 css를 먹이는 방법이 써져있지 않아 해맸으나, 여러 자료를 찾아 해결하였다.
- 로그인-인증기능을 구현할 때, 보안 이슈가 생겼었지만, hash함수를 이용하여 정보들을 암호화 했고, salt도 적용하여 조금더 보안에 초점을 맞췄다.
- 장바구니 기능을 구현할 때, 상태관리시 비동기화에 대한 개념이 부족하여, 정보가 바로 갱신이 되지 않았지만, 해당 내용을 학습후 다시 코딩을 진행해 제대로 구현하였다.
2. 사용된 기술
2.1 js
- nodemon: 개발환경에서의 편의성을 위해 사용
- prettier, eslint: 코드커멘션을 완성하기 위해 사용
2.2 app-server
- react-modal: 축제 상세보기에 modal창이 필요했는데 이때 사용
- react-cookie: 로그인기능을 유지하기위해 쿠키 기능 사용
2.3 api-server
- express: api-server 구현을 위해 사용
- cors: cors에러 해결을 위해 사용
- mysql2: api-server에서 mysql을 사용하기위해 사용
- crypto: 인증기능을 구현할때 hash함수 생성
2.4 python
- requests: python에서 api호출을 위해 사용
- pymysql: python에서 sql에 접근하기 위해 사용
- schedule: python에서 자동화 데이터 수집 자동화 알고리즘을 구현하기 위해 사용
3. 코드 분석
3.1 지도기능
1. 경도위도로 지역 찾기
// x:경도 y:위도 로 지역찾기
const getAdress = async function (x, y) {
try {
const res = await axios.get(
"https://dapi.kakao.com/v2/local/geo/coord2regioncode.json",
{
params: {
x: x,
y: y,
},
headers: {
Authorization: "카카오 api authorization",
},
}
);
if (res.data.documents[0].region_1depth_name) {
return res.data.documents[0].region_1depth_name;
} else {
return false;
}
} catch (error) {
// 오류 처리
console.error("오류:", error);
throw error; // 오류를 상위로 전파하거나 다른 방식으로 처리할 수 있습니다.
}
};
카카오 api를 이용하여 해당 지역명을 찾았다.
2. geolocation을 이용하여 해당 경도,위도 찾기
const getCurrentPos = function () {
if (navigator.geolocation) {
// HTML5의 geolocation으로 사용할 수 있는지 확인합니다
// GeoLocation을 이용해서 접속 위치를 얻어옵니다
navigator.geolocation.getCurrentPosition(
function (position) {
var lat = position.coords.latitude, // 위도
lon = position.coords.longitude; // 경도
dispatch(setMapItude({ newMapItude: [lon, lat, 8] }));
},
function (error) {
// 실패했을때 실행
dispatch(setMapItude({ newMapItude: [128.590752, 35.849918, 8] }));
}
);
} else {
// HTML5의 GeoLocation을 사용할 수 없을때 마커 표시 위치와 인포윈도우 내용을 설정합니다
dispatch(setMapItude({ newMapItude: [128.590752, 35.849918, 8] }));
}
};
접속위치를 얻어오는것을 실패하면 에러컨트롤을 통해 기본값을 설정했다.
3. 처음 마운트될때 지도 생성
//첫마운트 될때,
useEffect(function () {
map.current = new kakao.maps.Map(map.current, {
// 지도를 표시할 div
center: new kakao.maps.LatLng(35.95, 128.25), // 지도의 중심좌표
level: mapItude[2], // 지도의 확대 레벨
});
// 마커 클러스터러를 생성합니다
clusterer.current = new kakao.maps.MarkerClusterer({
map: map.current, // 마커들을 클러스터로 관리하고 표시할 지도 객체
averageCenter: false, // 클러스터에 포함된 마커들의 평균 위치를 클러스터 마커 위치로 설정
minClusterSize: 4, // 클러스터에 포함시킬 마커의 갯수
minLevel: 2, // 클러스터 할 최소 지도 레벨
});
}, []);
useRef를 이용하여 지도객체를 저장함
2. 로그인/인증기능
1. 정규식 이용하여 로그인 제한
const isValidPhoneNumber = (phoneNumber) => {
const regex = /^010-?\d{4}-?\d{4}$/;
return regex.test(phoneNumber);
};
const isValidPassword = (password) => {
const regex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,}$/;
return regex.test(password);
};
const isValidName = (name) => {
const regex = /^[가-힣]+$/;
return regex.test(name);
};
const isValidEmail = (email) => {
const regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
return regex.test(email);
};
const isValidId = (id) => {
const regex = /^[a-zA-Z0-9]+$/;
return regex.test(id);
};
정규식을 이용하였다.
2. 회원가입 요청
const res = await axios.post("/login/signup", {
login_id: idRef.current.value,
phone_number: pnRef.current.value,
password: await hasing(idRef.current.value + pwRef.current.value),
email: emailRef.current.value,
name: nameRef.current.value,
});
비밀번호는 해싱함수를 이용하여 클라이언트에서 보내주었다.
3. 해싱함수
import { SHA256 } from "crypto-js";
import axios from "axios";
const getSlt = async function () {
const res = await axios.get("/login/getslt");
return res.data;
};
const hasing = async function (secret) {
const hash = SHA256(secret + (await getSlt())).toString();
return hash.substring(0, 45);
};
export default hasing;
salt정보는 서버에 저장해 두었다.
3. 장바구니 기능
1. 장바구니 버튼을 누르면 요청하는 함수
const toFavor = function () {
if (!props.isFavor) {
dispatch(
pushFavor({
ticket: {
ticket_id: props.festival.id,
title: props.festival.title,
first_image: props.festival.first_image,
price: props.festival.price,
d_day: props.festival.d_day,
},
user_id: user_id,
})
);
} else {
dispatch(
popFavor({
ticket: {
ticket_id: props.festival.id,
},
user_id: user_id,
})
);
}
};
2. 해당기능을 api에서 구현
const pool = require("./pool");
const favoriteModel = {
// 좋아요 취소
async deleteFavor(fes, id, conn = pool) {
try {
const sql = `
delete from favorite where ticket_id = ? and user_id = ?
`;
const [result] = await conn.query(sql, [fes, id]);
return result.affectedRows;
} catch (err) {
throw new Error("DB Error", { cause: err });
}
},
// 좋아요
async updateFavor(fes, id, conn = pool) {
try {
const sql = `
insert into favorite(ticket_id, user_id) VALUES (?, ?)
`;
const [result] = await conn.query(sql, [fes, id]);
// console.log(result);
return result.affectedRows;
} catch (err) {
throw new Error("DB Error", { cause: err });
}
},
// 좋아요 목록 조회
async getFavor(id, conn = pool) {
try {
const sql = `
select
favorite.user_id,
favorite.ticket_id,
festival_api.title as title,
festival_api.first_image as first_image,
festival_api.addr1 as addr1,
festival_api.addr2 as addr2,
festival_api.price as price,
favorite.create_at,
datediff(event_end_date, now()) as d_day
from favorite
left join festival_api on favorite.ticket_id = festival_api.id
where favorite.user_id = ?
order by favorite.create_at desc
`;
const [result] = await conn.query(sql, [id]);
return result;
} catch (err) {
throw new Error("DB Error", { cause: err });
}
},
};
module.exports = favoriteModel;
api서버에서 클라이언트가 요청한 api를 처리함
4. 기타
1. popup 컴포넌트
import styles from "./popup.module.css";
const PopUp = function (props) {
return (
<div
className={
props.isActive
? `toast toast-3s fade show ${styles.toastPosition}`
: `toast toast-3s fade hide ${styles.toastPosition}`
}
role="alert"
aria-live="assertive"
data-delay="2000"
aria-atomic="true"
>
<div className="toast-header" style={{ backgroundColor: "#22b3c1" }}>
<img
src="/assets/images/logo2.png"
alt=""
className={`img-fluid m-r-5 ${styles.logoStyle}`}
/>
<strong className="mr-auto"></strong>
<small className="text-muted"></small>
</div>
<div className="toast-body">
<strong className="mr-auto">{props.body}</strong>
</div>
</div>
);
};
export default PopUp;
컴포넌트로 만들어, 재사용성을 높혔다. className을 이용해 팝업창을 키고 껏다.
2.pagination
const dispatch = useDispatch();
const page = useSelector((state) => state.myPageSlice.page);
// 페이지 갯수
const totalPage = [];
for (
let i = Math.max(1, page - 2);
i <= Math.min(props.lastPage, Math.max(page + 2, 5));
i++
) {
totalPage.push(i);
}
while (totalPage.length < 5) {
if (totalPage[0] == 1) {
if (totalPage || totalPage[totalPage.length - 1] == props.lastPage) {
break;
}
totalPage.push(totalPage[totalPage.length - 1] + 1);
} else {
if (totalPage[0]) totalPage.unshift(totalPage[0] - 1);
else break;
}
}
// 페이지 버튼
const pageButtons = totalPage.map((paging) => (
<li key={paging} className={paging == page ? "active" : ""}>
<Link
onClick={() => {
// 누른 페이지로 이동
dispatch(setPage({ newPage: paging }));
}}
>
{paging}
</Link>
</li>
));
const handleBackButtonClick = function () {
if (page > 1) {
dispatch(prev({ step: 1 }));
}
};
const handleNextButtonClick = function () {
if (page < props.lastPage) {
dispatch(next({ step: 1 }));
}
};
한페이지에 5페이씩 나오게 구현하였다.