로고
nabongsun.shop

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페이씩 나오게 구현하였다.

회원가입 기능 설명 지도 기능 설명 결제 기능 설명 관리자 기능 설명