Java/공중화장실 찾기

공중화장실 찾기 - 3 <네이버 지도 API 적용>

요술공주밍키 2024. 9. 9. 14:01

2024.09.08 - [Java/공중화장실 찾기] - 공중화장실 찾기 - 2 <공공데이터 가공>

 

공중화장실 찾기 - 2 <공공데이터 가공>

2024.09.06 - [Java/공중화장실 찾기] - 공중화장실 찾기 - 1 " data-og-description="최근 부쩍 단조로워진 나의 삶이 지루해져서 재미난 게 없을까 생각하다가 요 근래 속이 많이 안 좋아화장실을 찾는 일이

magicmk.tistory.com

 

지난 포스팅에서는 공공데이터를 가져와 csv 파일로 변경한 뒤 csv 데이터를 DB에 Insert 해보았다.

이번에는 DB에 들어있는 좌표 데이터를 가지고 네이버 지도 API를 이용하여 화면에 마킹해 보는 시간을 갖는다.


지도 화면 만들기

가장 먼저 아주 간단한 html과 js를 이용하여 네이버 지도를 불러온다.

 

1️⃣ 비즈니스 로직

🟠 GlobalController.java

package toy.project.suddenPoo.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class GlobalController {

    @Value("${map.id}")
    private String clientId;

    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("clientId", clientId);
        return "home";
    }
}

 

네이버 지도를 사용하기 위해서는 네이버 클라우드 플랫폼에서 생성한 클라이언트 아이디가 필요하기 때문에

해당 값을 컨트롤러를 통해 html로 넘겨준다.

 

🟠 ToiletControllerAPI.java

package toy.project.suddenPoo.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import toy.project.suddenPoo.csv.CsvDTO;
import toy.project.suddenPoo.dto.MapRangeDTO;
import toy.project.suddenPoo.service.ToiletService;

import java.util.List;

@Slf4j
@RestController
@RequiredArgsConstructor
public class ToiletControllerAPI {

    private final ToiletService toiletService;

    @PostMapping("/api/toilets")
    public List<CsvDTO> findToiletsByRange(@RequestBody MapRangeDTO mapRangeDTO) {
        return toiletService.findToiletsByRange(mapRangeDTO);
    }
}

 

지도를 움직였을 때 화면 범위에 있는 화장실 정보를 가져올 수 있도록 컨트롤러를 생성한다.

 

🟠 ToiletService.java

package toy.project.suddenPoo.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import toy.project.suddenPoo.csv.CsvDTO;
import toy.project.suddenPoo.dto.MapRangeDTO;
import toy.project.suddenPoo.entity.Csv;
import toy.project.suddenPoo.repository.ToiletRepository;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ToiletService {

    private final ToiletRepository toiletRepository;

    /**
     * 범위 내 화장실 찾기
     * @param mapRangeDTO 범위 값 DTO
     * @return
     */
    public List<CsvDTO> findToiletsByRange(MapRangeDTO mapRangeDTO) {
        List<Csv> toiletsByRange = toiletRepository.findToiletsByRange(mapRangeDTO.getSwLat(), mapRangeDTO.getNeLat(), mapRangeDTO.getSwLng(), mapRangeDTO.getNeLng());
        return toiletsByRange.stream().map(CsvDTO::new).toList();
    }
}

 

🟠 ToiletRepositoryImpl.java

package toy.project.suddenPoo.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
import toy.project.suddenPoo.entity.Csv;

import java.util.List;

import static toy.project.suddenPoo.entity.QCsv.csv;

@Repository
public class ToiletRepositoryImpl implements ToiletRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    public ToiletRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public List<Csv> findToiletsByRange(String swLat, String neLat, String swLng, String neLng) {
        return queryFactory
                .selectFrom(csv)
                .where(
                        csv.latitude.between(swLat, neLat)
                                .and(csv.longitude.between(swLng, neLng))
                )
                .fetch();
    }
}

 

이전 포스팅에서 ToiletRepositoryCustom을 얘기했었는데 QueryDSL을 통해 위와 같이 범위에 따른

화장실 정보를 가져온다.

2️⃣ 화면

🟠 home.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.ofg">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
    <title>급똥</title>
    <style>
        #searchBtn {
            position: absolute;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            padding: 10px 20px;
            background-color: #2b8cbe;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            cursor: pointer;
            z-index: 1000;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        }
    </style>
</head>
<body>
    <div id="map" style="width:100%;height:100vh;"></div>
    <button id="searchBtn" style="display:none;">현 지도에서 검색</button>

    <script type="text/javascript" th:src="'https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=' + ${clientId}"></script>
    <script src="/js/library/jquery-3.7.1.min.js"></script>
    <script src="/js/map.js"></script>
</body>
</html>

 

지도만 화면에 표출할 것이기 때문에 css도 html에 함께 넣었다.

 

🟠 map.js

const mapDiv = document.getElementById('map');
const zoom = 16;
const searchBtn = $('#searchBtn');

let map;
let markers = new Array();
let infoWindows = new Array();
let currentBounds;

// 현재 위치 파악
navigator.geolocation.getCurrentPosition(success, fail);

// 위치 정보 수락 시
function success(pos) {
    const lat = pos.coords.latitude;
    const lng = pos.coords.longitude;

    loadNaverMap(lat, lng, zoom);
    updateToilet();
}

// 위치 정보 거부 시
function fail(err) {
    loadNaverMap(37.3595704, 127.105399, zoom);
    updateToilet();
}

function loadNaverMap(lat, lng, zoom) {
    const position = new naver.maps.LatLng(lat, lng);
    const mapOptions = {
        center: position,
        zoom: zoom,
        minZoom: 7,                                 //지도의 최소 줌 레벨
        zoomControl: true,                          //줌 컨트롤의 표시 여부
        zoomControlOptions: {                       //줌 컨트롤의 옵션
            position: naver.maps.Position.TOP_RIGHT
        },
        tileTransition: true,                       // 타일 fadeIn 효과
        scaleControl: false,
        logoControl: true,
        mapDataControl: false,
        mapTypeControl: true
    }

    map = new naver.maps.Map(mapDiv, mapOptions);

    // 지도를 움직이면 '현 위치에서 찾기' 표출
    naver.maps.Event.addListener(map, 'idle', function() {
        const newBounds = map.getBounds();

        if (!currentBounds.equals(newBounds)) {
            searchBtn.show(); // 범위가 변경되면 버튼 표시
        } else {
            searchBtn.hide(); // 범위가 동일하면 버튼 숨김
        }
    });
}

function updateToilet() {
    const bounds = map.getBounds();
    currentBounds = bounds;

    const range = {
        swLat: bounds._sw._lat,
        neLat: bounds._ne._lat,
        swLng: bounds._sw._lng,
        neLng: bounds._ne._lng
    }

    // 기존 마커 제거
    removeAllMarkers();

    // 서버에서 데이터 가져오기
    $.ajax({
        url: "/api/toilets",
        method: "post",
        contentType: "application/json",
        data: JSON.stringify(range),
        success: function(res) {
           for (let i=0; i<res.length; i++) {
               const marker = new naver.maps.Marker({
                  map: map,
                  title: res[i].toiletName,
                  position: new naver.maps.LatLng(res[i].latitude, res[i].longitude)
               });

               const infoWindow = new naver.maps.InfoWindow({
                  content: '<div style="width:200px;text-align: center;padding: 10px"><b>' + res[i].toiletName +
                      '</b></br>' + res[i].roadName + '</div>'
               });
               markers.push(marker);
               infoWindows.push(infoWindow);
           }

            for (let i=0; i<markers.length; i++) {
                naver.maps.Event.addListener(markers[i], 'click', getClickHandler(i));
            }
        },
        error: function(req, status, error) {
           console.log(error);
        }
    });
}

// 해당 마커의 인덱스를 seq라는 클로저 변수로 저장하는 이벤트 핸들러를 반환합니다.
function getClickHandler(seq) {
    return function (e) {
        const marker = markers[seq],
            infoWindow = infoWindows[seq];

        if (infoWindow.getMap()) {
            infoWindow.close();
        } else {
            infoWindow.open(map, marker);
        }
    }
}

// 모든 마커 제거 함수
function removeAllMarkers() {
    for (let i = 0; i < markers.length; i++) {
        markers[i].setMap(null); // 지도에서 마커 제거
    }
    markers = [];

    for (let i = 0; i < infoWindows.length; i++) {
        infoWindows[i].close();
    }
    infoWindows = [];
}

searchBtn.on('click', function() {
    updateToilet();
    searchBtn.hide();
});

 

네이버 지도 API 활용에 대한 자세한 내용은 이전 포스팅에서 안내해준 예제 사이트에서 살펴볼 수 있다.

 

https://navermaps.github.io/maps.js.ncp/docs/tutorial-2-Getting-Started.html

 

NAVER Maps API v3

NAVER Maps API v3로 여러분의 지도를 만들어 보세요. 유용한 기술문서와 다양한 예제 코드를 제공합니다.

navermaps.github.io


 테스트

로컬에서 테스트를 해보면 현재 위치를 보여주며 화장실 위치가 마킹되는 것을 알 수 있다.

localhost


 마무리

정리 해놓고 보니 별거 없지만 사실 가져온 데이터를 지도에 마킹하는 부분에서 꽤 애를 먹었다.

예제에서는 분명 쉽게 쉽게 해 둔 것 같은데 다중 마킹에 대한 정보가 없어서 아마 못찾은걸지도..;;

아무튼 금방 해결할 수 있었다.

 

https://github.com/Kimmingki/suddenPoo

 

GitHub - Kimmingki/suddenPoo: 급똥이 마려울 때 얼른 화장실 찾아야지...

급똥이 마려울 때 얼른 화장실 찾아야지... Contribute to Kimmingki/suddenPoo development by creating an account on GitHub.

github.com