사실 기능 추가할 때마다 깃허브에 readme로 쓰고 있었다가... 이제서야 한번에 정리 겸 글 써봄


산책(이동) 경로 저장 기능 개발
01. 구상
25.01.13
01.12에 서버 배포를 완료한 뒤, 다음날에 새로운 프로젝트로 현위치 기반 맛집 추천을 해주는 웹을 만들고 싶었다. api를 다루는 방법을 익히고 싶어서 카카오 지도 api를 쓰는 주제를 선택한 거였는데, 막상 해보니 맛집 추천 정도는 백엔드가 필요 없고 프론트만으로도 해볼 수 있길래 포기했다.(백엔드 공부하는게 목적이라,,,)
그래도 지도 api는 써보고 싶어서, 그러면 기존에 챌린지 웹 만든 거에 산책 등 이동 경로를 저장하는 기능을 추가하기로 했다!
02. 카카오 지도 api 키 발급
위 사이트 우측 상단에서 APP KEY 발급을 한다. 그러면 내 애플리케이션 화면에서 앱 키가 나오는데, 여기서 지도 맵은 html 안의 <script> 태그 안에서 사용할 것이므로 JavaScript 키를 복사하여 application.properties에 추가하면 된다.


그리고 플랫폼 설정에서 도메인 등록을 해줘야 지도 이용이 가능하다. Web에서 http://localhost:8080를 추가하고(로컬 컴퓨터 용) 나중에 서버 배포할 때 그 주소도 넣어줘야 이용할 수 있다.(처음엔 이걸 안 넣어줘서 계속 오류났었다......)
또 제일 중요한 건 카카오맵 설정에서 활성화 버튼을 ON으로 해줘야 된다!!(이거 몰라서 또 한참 오류남22)

이렇게 하면 카카오맵에서의 설정은 끝났고, 처음 메인화면에서 여러 샘플들을 둘러보며 추가하고 싶은 기능에 대해 살펴볼 수 있다. ->https://apis.map.kakao.com/web/sample/

그 중에서 아래 기능을 추가해볼 것이다. 들어가면 코드 예제도 나와있어서 쉽게 참고할 수 있다.

03. 코드 작성
domain -> Location 생성
기존 ChallengeChecklist Entity에 추가하면 DB까지 바뀌어야 되므로, 새로운 Entity를 만들어서 join시켜줬다.
기본적인 id와, 마커를 표시하기 위한 위도/경도를 지정하였다. 그리고 기존 ChallengeChecklist Entity와 연결시킨 것도 추가한다.
@Entity
@Table(name = "locations")
@Getter @Setter
public class Location {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Double latitude; //위도
@Column(nullable = false)
private Double longitude; //경도
@ManyToOne
@JoinColumn(name = "checklist_id")
private ChallengeChecklist checklist;
}
domain -> ChallengeChecklist 수정
하나의 ChallengeChecklist Entity는 하나의 날짜 페이지라고 볼 수 있다. 날짜 페이지별로 날짜, 체크리스트 여부를 저장하고 있었는데, 여기에 지도 정보(마커)는 여러개 생성될 수 있다.(산책 간 곳마다 마커로 표시할 거라서) 따라서 @OneToMany로 연결시키고, 마커들을 List에 담았다. (새로 ArrayList로 생성해서 관리하면 수정해도 원본엔 영향을 안 미치도록 할 수 있어 안전하다.) 마커는 List에 추가, 삭제할 수 있다.
@OneToMany(mappedBy = "checklist", cascade = CascadeType.ALL, orphanRemoval = true)
List<Location> locations = new ArrayList<>();
public void addLocation(Location location) {
locations.add(location);
location.setChecklist(this);
}
public void removeLocation(Location location) {
locations.remove(location);
location.setChecklist(null);
}

repository -> LocationRepository 생성
새로 Entity를 만들었으므로 레포지토리도 JPA를 상속받는 것으로 하나 더 만들어 준다.
public interface LocationRepository extends JpaRepository<Location, Long> {
}
service -> ChallengeService 수정
기존에 checklist에 관한 메서드를 관리했던 서비스 코드에 지도에 관한 메서드도 추가하였다. 마커는 생성과 삭제만 있으면 되고, 이것들 역시 @Transactional로 하여 원자성을 보장하도록 한다.
지도는 각 날짜 페이지마다 있으므로, 날짜를 받아와서 checklist 정보를 담고 있는 레포지토리에서 해당 날짜 페이지 정보를 찾아온 다음, 위치정보를 생성해서 같이 저장해준다.
삭제는 위치 id를 통해 위치 정보를 담는 레포지토리에서 삭제해준다.
@Transactional
public void addLocation(LocalDate date, Double latitude, Double longitude) {
ChallengeChecklist checklist = repository.findByDate(date);
if (checklist != null) {
Location location = new Location();
location.setLatitude(latitude);
location.setLongitude(longitude);
checklist.addLocation(location);
repository.save(checklist);
}
}
@Transactional
public void removeLocation(Long locationId) {
locationRepository.deleteById(locationId);
}
dto -> LocationDTO 생성
controller를 작성하기 전에, 데이터 전달 객체인 DTO를 만들어준다. 사실 이걸 모르고 그냥 controller에서 바로 객체를 전달시켰더니, 지도 위치 정보인 JSON을 직렬화하는 과정에서 오류가 생겼다(불필요한 정보까지 담김). 이는 DTO를 만들어서 필요한 정보만 전달시키도록 하면 해결된다.
@Getter @Setter
public class LocationDTO {
private Long id;
private Double latitude;
private Double longitude;
}
controller -> ChallengeController 수정
application.properties에서 선언한 건 @Value로 가져올 수 있다. 이렇게 하면 자동으로 선언한 변수에 복사했던 키가 들어가게 된다.
@Value("${kakao.maps.api.key}")
private String kakaoMapsApiKey;
기존에 작성했던 날짜 페이지 컨트롤러에 위치 기능을 추가한다. 우선 checklist 객체에 담겨있는 location 정보를 stream으로 루프를 돌면서, DTO로 변환시킨다. 이후 이거를 model에 담아서 뷰로 전달한다.(api key도 전달해야 한다!)
@GetMapping("/date/{date}")
public String datePage(@PathVariable String date, Model model) {
date = date.replace("\"", "");
LocalDate parsedDate = LocalDate.parse(date);
ChallengeChecklist checklist = challengeService.getChallengeByDate(parsedDate);
// Location 정보를 DTO로 변환
List<LocationDTO> locationDTOs = checklist.getLocations().stream()
.map(location -> {
LocationDTO dto = new LocationDTO();
dto.setId(location.getId());
dto.setLatitude(location.getLatitude());
dto.setLongitude(location.getLongitude());
return dto;
})
.collect(Collectors.toList());
model.addAttribute("locations", locationDTOs);
model.addAttribute("kakaoMapsApiKey", kakaoMapsApiKey);
...
return "date"; // date.html
}
controller -> LocationController 생성
위치를 추가, 삭제할 땐 웹 화면에 표시되는 게 아니라 지도 맵에 표시될 것이므로 해당 컨트롤러는 HTTP 상태코드(200 ok)를 반환하도록 @RestController로 하였다. (REST API 요청 처리 컨트롤러)
특정 날짜에 새로운 위치를 추가(post)하는 건, 우선 요청 URL에서 날짜 정보를 받아서 매핑한 뒤 @RequestBody로 요청 본문에서 JSON형식의 위치 데이터를 가져올 것이다. (위도, 경도를 Map으로 받아옴)
기존 컨트롤러처럼 날짜를 받아왔다가 오류가 나서(...) 큰따옴표를 빼도록 파싱하였다. 그리고 서비스에서 구현했던 addLocation으로 저장한 뒤 HTTP 상태 코드 200(ok)를 반환한다. (build()로 본문이 없는 응답 객체(ResponseEntity)를 생성함)
삭제는 id를 받아와서 서비스에 전달한 후 마찬가지로 200 ok를 반환한다.
@RestController
@RequestMapping("/api/locations")
public class LocationController {
@Autowired
private ChallengeService challengeService;
@PostMapping("/{date}")
public ResponseEntity<?> addLocation(@PathVariable String date,
@RequestBody Map<String, Double> location) {
date = date.replace("\"", "");
LocalDate parseDate = LocalDate.parse(date);
challengeService.addLocation(parseDate, location.get("latitude"), location.get("longitude"));
return ResponseEntity.ok().build();
}
@DeleteMapping("/{locationId}")
public ResponseEntity<?> removeLocation(@PathVariable Long locationId) {
challengeService.removeLocation(locationId);
return ResponseEntity.ok().build();
}
}
★두 컨트롤러가 분리된 이유 - 책임 분리, 확장성
-ChallengeController: 전체 챌린지를 담당하며 뷰 렌더링 역할
-LocationController: RESTful API 호출 담당
★동작 순서
1. 클라이언트: 위치 마커 추가 -> POST /api/locations/{date}로 요청 보냄
2. LocationController: 데이터를 받아서 서비스를 통해 ChallengeChecklist Entity에 저장 -> 클라이언트에게 200 ok 반환
3. 클라이언트: 마커를 보고싶으니 GET /date/{date}로 요청
4. ChallengeController: 해당 날짜에 맞는 ChallengeChecklist Entity에 담겨있는 위치 정보를 꺼내 DTO로 변환 -> DTO를 모델에 담아서 뷰로 전달
5.date.html: 뷰를 렌더링하여 클라이언트에게 전달 -> 지도 맵에 위치 마커 표시됨
=> 지도 맵에서 클릭하면 바로 마커가 보여짐
templates -> date.html 수정
날짜 페이지에 카카오 맵 api 키를 script로 추가한다. (모델로 전달받음)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DATE</title>
<script type="text/javascript" th:src="@{'//dapi.kakao.com/v2/maps/sdk.js?appkey=' + ${kakaoMapsApiKey}}"></script>
<link rel="stylesheet" href="/css/style.css">
</head>
체크리스트 화면 밑에 지도를 담을 div를 추가한다. 여기서 경로 보기를 누르면 마커끼리 선으로 이어지고, 경로 지우기를 하면 선 및 모든 마커가 삭제된다.(원래는 선만 지워지고 마커는 우클릭으로 하나씩 삭제하는 것으로 했었는데, 이러니까 모바일에서는 우클릭을 할 수 없어 지울 수가 없었음... 그래서 그냥 경로 지우기 버튼 누르면 다 지워지도록 해서 모바일도 편하게 수정할 수 있도록 함)
<div class="map-controls">
<button class="map-button" onclick="togglePath()">경로 보기</button>
<button class="map-button" onclick="clearPath()">경로 지우기</button>
</div>
<div id="map"></div>
이후 자바스크립트 부분은 너무 길어서... 중요한 코드만,,, (대부분은 카카오 사이트에 있는 코드를 활용했다.)
우선 자바스크립트에서 아래처럼 쓰면 주석이 아니라 동적으로 추가하는 부분이란 걸 새롭게 알았다. 이렇게하면 렌더링 전에도 오류나는 걸 피할 수 있다고 한다. 렌더링 되면 모델로 넘겼던 위치 객체를 사용할 수 있다.
// 저장된 위치 정보로 마커 생성
var savedLocations = /*[[${locations}]]*/ [];
지도를 클릭하면 마커가 생성된다. 아까 동작 순서에서 설명한 것처럼, LocationController로 요청을 보낸다. 컨트롤러에서 @RequestBody로 받는 것도 여기서 json으로 위치 정보를 실어서 보내기 때문이다. 컨트롤러에서 ok 반환이 오면 마커를 생성하고 위치 정보를 담아 지도에 표시한다. 또한 경로도 업데이트 하고, 마커를 생성할 때마다 페이지가 새로고침되도록 한다.(바로 서버에 반영됨)
// 지도 클릭 이벤트
kakao.maps.event.addListener(map, 'click', function(mouseEvent) {
var latlng = mouseEvent.latLng;
fetch(`/api/locations/[[${date}]]`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
latitude: latlng.getLat(),
longitude: latlng.getLng()
})
}).then(response => {
if (response.ok) {
var marker = new kakao.maps.Marker({
position: latlng,
map: map
});
markers.push({
marker: marker,
id: null,
position: latlng
});
// 새 마커가 추가되면 경로도 업데이트
if (isPathVisible) {
drawPath();
}
location.reload(); // 페이지 새로고침
}
});
});
삭제도 비슷하다. 우클릭 이벤트가 발생하면, LocationController에 삭제 요청을 보낸다. ok가 반환되면 배열에서 찾아서 해당 마커를 지우고 경로를 업데이트 한 뒤 새로고침 한다.
// 마커 우클릭으로 삭제
markers.forEach(function(markerObj) {
kakao.maps.event.addListener(markerObj.marker, 'rightclick', function() {
if (markerObj.id) {
fetch(`/api/locations/${markerObj.id}`, {
method: 'DELETE'
}).then(response => {
if (response.ok) {
markerObj.marker.setMap(null);
markers = markers.filter(m => m.id !== markerObj.id);
// 마커가 삭제되면 경로도 업데이트
if (isPathVisible) {
drawPath();
}
location.reload(); // 페이지 새로고침
}
});
}
});
});
날짜 페이지에 처음 들어가면 사용자 기반 현 위치가 나타나게 할 것이다. 만약 브라우저에서 위치 탐색을 허용하지 않았다면 기본 위치인 서울로 나타난다.
// 현재 위치 가져오기 (기존 코드 유지)
function getCurrentLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function(position) {
initMap(position.coords.latitude, position.coords.longitude);
},
function(error) {
console.error("현재 위치를 가져올 수 없습니다:", error);
initMap(37.566826, 126.978656);
},
{
enableHighAccuracy: true,
maximumAge: 0,
timeout: 5000
}
);
} else {
console.error("이 브라우저에서는 위치 정보를 지원하지 않습니다.");
initMap(37.566826, 126.978656);
}
}
// 페이지 로드 시 실행
getCurrentLocation();
04. 실행
제일 뿌듯한 장면............
아무튼 이렇게 경로를 자유롭게 추가/경로 보기/1개씩 삭제/전체 삭제를 할 수 있다.
하루에 이동한 경로를 저장할 수 있어서 완전 유용하게 쓰고 있다ㅎ.ㅎ
+
여기에 나머지 기능 개발한 것도 다 쓰려고 했는데 너무 길어져서 나누는게 나을 것 같음
일기 생성 기능은 다음편으로~.~
'🌟Project > Challenge - 100일 챌린지 웹' 카테고리의 다른 글
| [토이프로젝트 개발일지 04] 일기 생성 기능 추가 / 메인 페이지 보완 (4) | 2025.01.21 |
|---|---|
| [토이프로젝트 개발일지 02] Spring Boot, MariaDB 배포(CloudType) (1) | 2025.01.12 |
| [토이프로젝트 개발일지 01] 100일 챌린지 웹 (0) | 2025.01.11 |