ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Naver MAP API를 이용해 지도 구현
    취준/Project 2024. 6. 16. 16:04

     
    다른 아르바이트 사이트들의 단점을 보완하기 위해 위치 기반의 아르바이트 웹 페이지를 구현하고자 하였다.
    그래서 지도를 기반으로 내 주변 아르바이트 일자리를 쉽게 찾아볼 수 있도록 하였고 이 기능을 위해서는 지도 API가 필수적이었다.
     
     
     

     


    Naver MAP API 호출

     

    네이버 클라우드 콘솔 회원가입

    https://www.ncloud.com/product/applicationService/maps

    NAVER CLOUD PLATFORM

    cloud computing services for corporations, IaaS, PaaS, SaaS, with Global region and Security Technology Certification

    www.ncloud.com

    • 네이버 클라우드 플랫폼에 들어가 회원가입을 하고 결제정보를 등록한다

     

    기술 문서
    https://navermaps.github.io/maps.js.ncp/docs/

    NAVER Maps API v3

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

    navermaps.github.io

    • 기술 문서가 상당히 잘 정리되어 있다. 
    • Examples에 다양한 예제와 소스코드들이 구현되어 있어 쉽게 문서를 보고 구현할 수 있다.

     

    프로젝트 정보 등록, client-id 발급

    • maps에서 사용하고자 하는 서비스를 선택하고 Web서비스이기 때문에 프로젝트 URL을 등록하여 준다.
    • 성공적으로 발급된 Client-ID를 복사하여 저장해 둔다.

     

    naver.map.client-id= {Cilent-ID}
    @Controller
    public class MainController {
        @Value("${naver.map.client-id}")
        private String naverMapClientId;
    
        private final Logger logger = LoggerFactory.getLogger(MainController.class);
        @GetMapping("/")
        public String home(Model model){
            model.addAttribute("naverMapClientId", naverMapClientId);
            return "home";
        }
    }
    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org"
          xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
          layout:decorate="~{layouts/default_layout}">
    <th:block layout:fragment="content">
        <div class="container text-center" style="width: 100%;">
            <div class="row">
                <!-- 검색창과 상점 정보가 나오는 부분 -->
                <div class="col-3 d-flex align-items-center justify-content-start">
                    <div id="store-list"></div>
                </div>
                <!-- 지도 부분 -->
                <div class = "col d-flex align-items-center justify-content-center" id="map" style="width: 100%; height: 800px;"></div>
            </div>
        </div>
    
        <!-- 네이버 지도 API 스크립트 -->
        <script type="text/javascript" th:src="@{|https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${naverMapClientId}&callback=initMap|}"></script>
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
        <script type="text/javascript" th:src="@{/user/map.js}"></script>
        <link rel="stylesheet" href="/user/home.css"/>
    </th:block>
    </html>
    • properties에 Client-ID를 넣어주고 Controller에서 Map이 호출되는 화면에 Client-ID의 변숫값을 model에 담아 넘겨준다.
    • <div id = "map"></div>에 실제 지도를 넣어줄 것이고 지도의 설정은 js 파일에서 해줘야 한다.

     

    map.js

    function initMap() {
        if (navigator.geolocation) {
            navigator.geolocation.getCurrentPosition(showPosition, showError);
        } else {
            defaultPosition();
        }
    }
    
    // 위치 가져오기 성공 함수
    function showPosition(position) {
        var lat = position.coords.latitude;
        var lng = position.coords.longitude;
    
        var map = new naver.maps.Map('map', {
            center: new naver.maps.LatLng(lat, lng),
            zoom: 15,
            maxZoom: 17,
            minZoom: 8,
            zoomControl: true,
            zoomControlOptions: {
                position: naver.maps.Position.TOP_RIGHT
            }
        });
    }
    
    // 기본 위치
    function defaultPosition() {
        var lat = 37.3595704;
        var lng = 127.105399;
    
        var map = new naver.maps.Map('map', {
            center: new naver.maps.LatLng(lat, lng),
            zoom: 15,
            maxZoom: 18,
            minZoom: 8,
            zoomControl: true,
            zoomControlOptions: {
                position: naver.maps.Position.TOP_RIGHT
            }
        });
    }
    • 사용자의 현재 위치를 받아오기 위해 navigator.geolocation.getCurrentPosition(showPosition, showError);을 이용한다.  사용자의 현재 위치를 받아오는 것을 성공하면 showPosition 함수를, 실패하면 showError 함수를 호출한다.
    • showError 함수는 임의의 좌표를 기반으로 기본값으로 세팅되어 있는 defaultPosition 함수를 사용하도록 하였다.

     


    지도 범위 내에 있는 데이터 비동기 호출

     

    getStore 함수 정의

    function getStore(map) {
        var bounds = map.getBounds();
        var ne = bounds.getNE();
        var sw = bounds.getSW();
    
        $.ajax({
            url: '/getStore',
            method: 'GET',
            contentType: 'application/json',
            data: {
                northEastLat: ne.lat(),
                northEastLng: ne.lng(),
                southWestLat: sw.lat(),
                southWestLng: sw.lng()
            },
            success: function(response) {
                console.log('get Store from backend :', response);
                clearMarkers();
                clearStoreList();
                response.forEach((store, index) => {
                    var marker = new naver.maps.Marker({
                        position: new naver.maps.LatLng(store.area_lat, store.area_lng),
                        map: map
                    });
                    markers.push({marker: marker, storeId: store.store_id});
                    appendStoreToList(store, index);
                });
            },
            error: function(xhr, status, error) {
                console.error('백엔드로 좌표 전달 실패:', error);
            }
        });
    }
    • map.getBounds 함수를 이용하면 현재 지도에서 표시하고 있는 북동쪽 위도 경도, 남서쪽 위도 경도를 받아올 수 있다. AJAX를 이용하여 4가지 값을 Server에 전달한다. 
    • js코드에서는 전달받은 response를 반복문을 돌면서 marker 객체를 생성하고 marker에 데이터의 위도, 경도, map을 할당하여 준다.

    Controller

    @Data
    @AllArgsConstructor
    @Builder
    @ToString
    public class CoordinateDto {
        public double northEastLat;
        public double northEastLng;
        public double southWestLat;
        public double southWestLng;
    }
        @GetMapping("/getStore")
        @ResponseBody
        public ResponseEntity<List<StoreFindResultDto>> getStore(@RequestParam("northEastLat") double northEastLat, @RequestParam("northEastLng") double northEastLng,
                                                                 @RequestParam("southWestLat") double southWestLat, @RequestParam("southWestLng") double southWestLng)
        {
            CoordinateDto coordinateDto = new CoordinateDto(northEastLat, northEastLng, southWestLat, southWestLng);
            List<StoreFindResultDto> storeList = storeService.findStoresWithinBounds(coordinateDto);
            return ResponseEntity.ok(storeList);
        }
    • AJAX에서 호출되는 Controller이다. 4가지 위도, 경도를 이용하여 CoordidateDto를 생성한다.
    • Service단에서는 CoordanateDto의 4가지 값을 이용하여 데이터를 DB에서 조회할 것이다.

    Serivce

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    @ToString
    public class StoreFindResultDto {
        public Long store_id;
        public String store_name;
        public double area_lat;
        public double area_lng;
        public int store_salary;
        public String image_url;
        public String work_days;
        public LocalDateTime start_time;
        public LocalDateTime end_time;
        public WorkType type;
    
    
        public StoreFindResultDto toDto(Store store){
            return   StoreFindResultDto.builder()
                    .store_id(store.getStore_id())
                    .store_name(store.getStore_name())
                    .area_lat(store.getArea_lat())
                    .area_lng(store.getArea_lng())
                    .store_salary(store.getStore_salary())
                    .image_url(store.getStore_picture_url())
                    .work_days(store.getWork_days())
                    .start_time(store.getStart_time())
                    .end_time(store.getEnd_time())
                    .type(store.getType())
                    .build();
        }
    
    }

     

    @Service
    @RequiredArgsConstructor
    public class StoreService {
    
        private final StoreRepository storeRepository;
        private final Logger logger = LoggerFactory.getLogger(StoreService.class);
    
        @Transactional
        public List<StoreFindResultDto> findStoresWithinBounds(CoordinateDto coordinateDto) {
            logger.info("find store in " + coordinateDto);
            List<Store> storeList = storeRepository.findStoresWithinBounds(
                    coordinateDto.getNorthEastLat(),
                    coordinateDto.getNorthEastLng(),
                    coordinateDto.getSouthWestLat(),
                    coordinateDto.getSouthWestLng()
            );
    
            List<StoreFindResultDto> storeFindResultDtoList = new ArrayList<>();
            for (Store store : storeList){
                storeFindResultDtoList.add(new StoreFindResultDto().toDto(store));
            }
            logger.info("result counting : " + storeFindResultDtoList.size());
            return storeFindResultDtoList;
        }
    
    }
    • Service에서는 CooridanateDto를 이용하여 Repository에 파라미터를 전달한다. 
    • 결괏값을 담을 StoreFindResultDto를 생성하고 toDto 함수를 정의하여 Store의 결과 DTO를 생성하여 Controller에 전달할 수 있도록 해준다.

     

    Repository

    public interface StoreRepository extends JpaRepository<Store, Long> {
    
        @Query("SELECT s FROM Store s " +
                "WHERE s.area_lat BETWEEN :southWestLat AND :northEastLat " +
                "AND s.area_lng BETWEEN :southWestLng AND :northEastLng")
        List<Store> findStoresWithinBounds(
                @Param("northEastLat") double northEastLat,
                @Param("northEastLng") double northEastLng,
                @Param("southWestLat") double southWestLat,
                @Param("southWestLng") double southWestLng
        );
    }
    • 4가지 매개변수를 이용하여 쿼리를 작성한다.  남서쪽 위도 <= 위도 <= 북동쪽 위도 AND 북동쪽 경도 <= 경도 <= 남서쪽 경도를 만족하는 Store를 모두 조회하여 List에 담아낸다.
    • 추후에 성능 개선을 위해서는 store에 지역과 현재 Map 바운더리 내에 지역을 특정하여 1차 필터링을 하고 데이터를 조회하면 성능 튜닝이 가능할 것 같다.

     

    Param for query method parameters, or when on Java 8+ use the javac flag -parameters

    • java8 이상일 경우 -parameters를 선언해 주면 Repository에서 쿼리에 파라미터를 전달해 줄 경우 매개변수를 따로 명시하지 않아도 된다고 하였지만 나의 경우 -parameters를 선언해도 잘 되지 않았다.
    • 그래서 -parameters 세팅과 @Param을 동시에 선언하다 보니 해결되었다.

     

    그밖에 JS 코드들

    function addMapEventListeners(map) {
        naver.maps.Event.addListener(map, "dragend", () => {
            getStore(map);
        });
        naver.maps.Event.addListener(map, "zoom_changed", () => {
            getStore(map);
        });
    }
    
    function clearMarkers() {
        markers.forEach(markerObj => markerObj.marker.setMap(null));
        markers = [];
    }
    
    function clearStoreList() {
        $('#store-list').empty();
    }
    
    function appendStoreToList(store, index) {
        const storeItem = `
            <div class="row store-item" style="cursor: pointer;"
                onmouseover="highlightMarker(${index}, true)"
                onmouseout="highlightMarker(${index}, false)"
                onclick="goToDetail(${store.store_id})">
                <div class="col-6 store-image-container">
                    <img src="/images/${store.image_url}" alt="${store.store_name}" style="width: 100%; height: auto;" class="store-image">
                </div>
                <div class="col store-details">
                    <h4 class="store-name">${store.store_name}</h4>
                    <p class="store-salary">시급: ${store.store_salary}</p>
                    <p class="store-work-days">근무요일: ${store.work_days}</p>
                </div>
            </div>
        `;
        $('#store-list').append(storeItem);
    }
    
    function highlightMarker(index, onHover) {
        const markerObj = markers[index];
        if (onHover) {
            markerObj.marker.setAnimation(naver.maps.Animation.BOUNCE);
        } else {
            markerObj.marker.setAnimation(null);
        }
    }
    
    function goToDetail(storeId) {
        window.location.href = `/detailStore/${storeId}`;
    }

     
    addMapEventListeners

    • 지도가 움직이거나, 줌의 범위가 변경되면 getStore함수를 호출하도록 2가지 이벤트 리스너를 달아주었다.

    clearMarkers

    • getStore함수가 호출되면 새로운 데이터들을 성공적으로 전달받으면 기존에 있던 marker 삭제를 위한 함수이다.
    • 마커가 200개 이상 맵에 표현되면 성능에 심각한 영향을 끼친다고 하기 때문에 성능을 위해 필요하다고 생각되는 함수이다.

    clearStoreList

    • 지도에 포함되는 데이터의 정보를 보여주는 storeList를 삭제하는 함수이다.

    appendStoreToList

    • store들이 표현될 div를 정의한다. 전달받은 데이터를 store-list에 append 한다.

    highlightMarker

    • UX 향상을 위해 데이터 정보를 보여주는 storeList에 마우스를 올리면 지도에 포함되는 marker가 반응을 보이도록 하는 함수이다.
    • 네이버에서 제공하는 Animation을 달아주어 해당 데이터의 위치가 어디인지 지도에서 보기 쉽게 표시한다.

    goToDetail

    • store의 세부 정보 페이지로 들어가도록 하는 함수이다.

    Test Code

    package com.example.albaya;
    
    import com.example.albaya.enums.WorkType;
    import com.example.albaya.store.dto.CoordinateDto;
    import com.example.albaya.store.dto.StoreFindResultDto;
    import com.example.albaya.store.dto.StoreSaveDto;
    import com.example.albaya.store.entity.Store;
    import com.example.albaya.store.service.StoreService;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.time.LocalDateTime;
    import java.util.List;
    
    @SpringBootTest
    @Transactional
    public class StoreTest {
    
        @Autowired
        private StoreService storeService;
    
        @Test
        @DisplayName("Find store test within bound")
        public void findStoreTestWithinBounder(){
            StoreSaveDto storeSaveDto_1 = StoreSaveDto.builder()
                    .store_name("store_test1")
                    .area_lat(37.0600000)
                    .area_lng(127.0500000)
                    .store_salary(2000)
                    .work_days("월-화")
                    .start_time(LocalDateTime.now())
                    .end_time(LocalDateTime.now())
                    .type(WorkType.PART)
                    .build();
    
            StoreSaveDto storeSaveDto_2 = StoreSaveDto.builder()
                    .store_name("store_test2")
                    .area_lat(37.0700000)
                    .area_lng(127.0700000)
                    .store_salary(2000)
                    .work_days("월-화")
                    .start_time(LocalDateTime.now())
                    .end_time(LocalDateTime.now())
                    .type(WorkType.PART)
                    .build();
    
            StoreSaveDto storeSaveDto_3 = StoreSaveDto.builder()
                    .store_name("store_test3")
                    .area_lat(37.0792107)
                    .area_lng(127.0700000)
                    .store_salary(2000)
                    .work_days("월-화")
                    .start_time(LocalDateTime.now())
                    .end_time(LocalDateTime.now())
                    .type(WorkType.PART)
                    .build();
    
            CoordinateDto coordinateDto = CoordinateDto.builder()
                    .northEastLat(37.0792105)
                    .northEastLng(127.0835125)
                    .southWestLat(37.0586649)
                    .southWestLng(127.0416701)
                    .build();
    
            storeService.saveStore(storeSaveDto_1);
            storeService.saveStore(storeSaveDto_2);
            storeService.saveStore(storeSaveDto_3);
    
            List<StoreFindResultDto>storeFindResultDtoList = storeService.findStoresWithinBounds(coordinateDto);
            Assertions.assertEquals(storeFindResultDtoList.size(), 2);
        }
    
    
    }

     
     

    댓글

Designed by Tistory.