1. 목표
현재 관광지에서 네이버 지도 비동기 API를 호출하여, 방문하지 않는 관광지 중 가장 가까운 관광지 구하기
2. 문제 상황
당일치기 여행 알고리즘은 "그리디 알고리즘" 으로
현재 위치에서 가장 가까운 노드로 이동하고, 그 다음에도 마찬가지 방식으로 가장 가까운 노드를 선택하여 이동한다.
'가깝다' 라는 기준은 네이버 지도 API를 호출하여, 각 노드 간의 거리를 파악한다.
(이 때, 탐색 옵션은 trafast로 "실시간 교통 상황을 반영한 차로 이동하는데 걸리는 시간"이 cost가 된다.)
https://api.ncloud-docs.com/docs/ai-naver-mapsdirections-driving
3. 개발
Service 로직의 정리와 구현하면서 생긴 이슈를 다루는데 포커스를 맞추고 있어,
DTO 의 각 필드와 Repository 의 설명은 제외하였습니다.
1) Controller 정의
우선 유저가 앱 서비스 내에서 가고자 하는 관광지를 선택해, 당일치기 일정 생성을 할 것이다.
그럼 클라이언트에서 유저가 선택한 관광지 정보들을 Request 요청으로 보내고,
서버는 이를 당일치기
1. HttpClient 생성
HTTP 연결을 위한 HttpClient 객체를 생성한다.
추가로 연결∙응답 타임아웃을 10초로 설정한다.
public Node getNearNode(Node start, List<Node> allNodes, Map<Long, Boolean> visited) {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(10000));
2. WebClient 생성
Web 통신을 위한 WebClient 객체를 생성한다.
앞서 설정한 HttpClient를 사용하여, WebClient의 클라이언트 커넥터를 설정한다.
(ReactorClientHttpConnector: HttpClient를 사용해 Http 요청을 보낼 수 있도록 함)
요청을 보낼 URL는 uriPath 로,
모든 요청에 대한 기본 헤더 Content-Type을 application/json으로 설정한다.
private static final String uriPath = "https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving";
WebClient webClient = WebClient
.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl(uriPath)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
3. 네이버 지도 비동기 API 호출
Flux 비동기 API 호출로, 현재 위치에서 "각 미방문 관광지까지의 걸리는 시간"을 구한다.
(Flux Stream에 대한 이해는 아래에서..)
List<Map<Long, Long>> nearDuration
→ List<(관광지 id, 걸리는 시간)>
// Mono: 0~1개의 아이템을 비동기적으로 처리할 수 있는 Reactor 데이터 스트림
// Flux: 0~N개의 아이템을 비동기적으로 처리할 수 있는 Reactor 데이터 스트림
Flux<Node> nodesFlux = Flux.fromIterable(allNodes).filter(node -> !visited.get(node.getId()));
List<Map<Long, Long>> nearDuration = nodesFlux.flatMap(node ->
webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("start", start.getLongitude() + "," + start.getLatitude())
.queryParam("goal", node.getLongitude() + "," + node.getLatitude())
.queryParam("option", "trafast") // trafast API 요청
.build())
.header("X-NCP-APIGW-API-KEY-ID", CLIENT_ID)
.header("X-NCP-APIGW-API-KEY", CLIENT_SECRET)
.retrieve()
.bodyToMono(NaverResponseDto.class)
.map(response -> {
return Map.of(node.getId(), response.getRoute().getTrafast().get(0).getSummary().getDuration());
})
).collectList().block();
4. 가장 가까운 관광지 찾기
nearDuration에는 List<Map<미방문 노드 id, start 노드와 미방문 노드와의 거리>> 으로 저장되어 있다.
가장 가까운(짧은) 노드의 id를 찾아, 전체 노드 중 해당하는 노드를 반환한다.
Long nearId = 0L;
Double shortTime = Double.MAX_VALUE;
for (Map<Long, Long> near : nearDuration) {
for (Long key : near.keySet()) {
double time = near.get(key);
if (shortTime > time) {
shortTime = time;
nearId = key;
}
}
}
for (Node oneNode : allNodes) {
if (oneNode.getId() == nearId) {
return oneNode;
}
}
4. 개발하면서 발생한 이슈들
1) Flux Stream 이해 과정
List<Node> unvisitedNodes = allNodes.stream().filter(node -> !visited.get(node.getId())).toList();
Flux<Node> nodesFluxes = Flux.fromIterable(allNodes).filter(node -> !visited.get(node.getId()));
신기하게도 unvisitedNodes와 nodesFluxes 노드의 List 사이즈가 다르게 나왔다!
래퍼런스를 찾아본 결과,
Flux는 기본적으로 지연 실행(Lazy execution)으로, 실제로 데이터가 소비되기 전까지 filter 처리가 지연이 된다.
정리하면 다음과 같다.
1. Flux 생성 및 필터링 정의
allNodes 컬렉션을 Flux로 변환하고, 이를 필터링하는 연산을 "정의"한다.
그러나 이 시점에서는 실제 데이터 처리가 이뤄지지 않는다.
Flux<Node> nodesFluxes = Flux.fromIterable(allNodes).filter(node -> !visited.get(node.getId()));
2. flatMap 및 외부 API 호출 정의
flatMap 연산을 통해 각 노드에 대해 외부 API 호출을 "정의"한다.
하지만 이 시점에서도 여전히 실제 데이터 처리가 이뤄지지 않는다.
List<Map<Long, Long>> nearDuration = nodesFlux.flatMap(node ->
webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("start", start.getLongitude() + "," + start.getLatitude())
.queryParam("goal", node.getLongitude() + "," + node.getLatitude())
.queryParam("option", "trafast")
.build())
.header("X-NCP-APIGW-API-KEY-ID", CLIENT_ID)
.header("X-NCP-APIGW-API-KEY", CLIENT_SECRET)
.retrieve()
.bodyToMono(NaverResponseDto.class)
.map(response -> {
return Map.of(node.getId(), response.getRoute().getTrafast().get(0).getSummary().getDuration());
})
)
3. 데이터 소비 및 block
block 호출에 의해 비로소 Flux 스트림이 구독(subscribe)이 되고, 데이터 처리가 시작된다.
이 시점에서 Flux 스트림의 filter 연산, flatMap을 통한 비동기 API 호출과 같은 모든 연산이 실행이 된다.
그리고 이러한 연산이 끝날 때까지 현재 Thread는 block 상태가 된다.
그러므로 Flux 스트림이 끝난 후의 상태에서는 !visited.get(node.getId()) 처리가 되어 있다.
.collectList().block();
2) AtomicReference
정확히 말하면 AtomicReference의 이슈가 아니다.
비동기 호출 과정에서 "start 노드에서 미방문 노드까지의 duration을 파싱하지 못한 문제"인 듯 하다.
코드를 상당히 간추리긴 했지만, WebClient 비동기 로직 내에서 현재 start 노드와 주변 노드들 사이의 거리를 측정해
가장 짧은 노드의 정보를 nearestNode에 담으려고 했으나 if 최단거리 조건문을 들어가지 못해 계속 start 노드가 반환되었다.
디버깅해서 찾아보고 싶었지만 비동기 호출이라 코드에 대한 문제점을 찾지 못하고, 수정하는 방향으로 갔다.
AtomicReference<Node> nearestNode = new AtomicReference<>(start);
AtomicReference<Double> shortestDistance = new AtomicReference<>(Double.MAX_VALUE);
.map(response -> {
// 여기서 "start 노드와 주변 노드 간의 거리 구하는 로직"을 구현
double duration = parseDistance(response);
if (duration < shortestDistance.get()) {
shortestDistance.set(duration);
nearestNode.set(node);
}
return node;
})
private double parseDistance(NaverResponseDto response) {
if (response != null &&
response.getRoute() != null &&
response.getRoute().getTrafast() != null &&
!response.getRoute().getTrafast().isEmpty() &&
response.getRoute().getTrafast().get(0).getSummary() != null) {
return response.getRoute().getTrafast().get(0).getSummary().getDuration(); // 거리 정보를 반환함
}
return Double.MAX_VALUE; // 거리 정보를 찾을 수 없는 경우, 최대값을 반환하여 이 노드를 선택하지 않도록 함
}
3) DTO 빈 기본 생성자
네이버 지도 api 호출 후, Response를 담을 DTO 정의가 필요했다.
여기에서 빈 기본 생성자 없이 api 호출이 일어나면 다음과 같은 에러가 일어난다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.core.codec.CodecException: Type definition error: [simple type, class com.example.kotrip.dto.daytrip.NaverRouteResponseDto]] with root cause
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of com.example.kotrip.dto.daytrip.NaverRouteResponseDto
위 에러는 @RequestBody로 받을 Dto 클래스에 기본 빈 값 생성자가 있어야 하는데,
없기 때문에 에러가 발생한 것으로 각 하위 DTO 클래스에 빈 생성자를 만들어줌으로써 에러를 해결할 수 있었다.
@Getter
public class NaverRouteResponseDto {
private List<NaverSummaryResponseDto> trafast;
protected NaverRouteResponseDto() {
}
public NaverRouteResponseDto(List<NaverSummaryResponseDto> trafast) {
this.trafast = trafast;
}
}
'Spring > 끄적끄적' 카테고리의 다른 글
Spring Boot에서의 엑셀 다운로드 API (0) | 2024.07.20 |
---|---|
@JoinColumn(name =" ", referecedColumnName= " ") (0) | 2024.07.15 |
HttpURLConnection, RestTemplate, WebClient 비교 (0) | 2024.07.11 |
WebClient 네이버 지도 비동기 API 호출하기 (1) (0) | 2024.05.20 |