주문(Order): 업체가 요청한 주문
배송(Delivery): 주문에 대한 배송 정보
배송 상세 경로(DeliveryRecords): 배송이 경유지를 거치는 단계를 상세히 기록한 정보
일단 배송은 주문 생성 시점에 생성된다.
배송에는 다음과 같은 정보가 포함되어 있다.
public class Deliveries extends BaseEntity {
@Id
@GeneratedValue
@UuidGenerator
@Column(updatable = false, nullable = false)
private UUID deliveryId;
@Column(nullable = false)
private UUID sourceHubId; // 출발 허브 id
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private DeliveryStatusEnum status = DeliveryStatusEnum.HUB_WAIT; // 배송 상태
@Column(nullable = false)
private String companyAddress; // 배송지 주소
@Column(nullable = false)
private String recipient; // 수령인
@Column(nullable = false)
private String recipientSlackAccount; // 수령인 slack 계정(id)
@Column(nullable = false)
private LocalDateTime dispatchDeadline; // 배송을 시작해야 하는 데드라인
private Integer totalSequence; // 총 시퀀스 수(DeliveryRecords 수)
private Integer currentSeq; // 현재 진행중 시퀀스 번호
@Column(nullable = false)
private UUID orderId;
@Column(nullable = false)
private UUID companyId;
@OneToMany(mappedBy = "delivery", cascade = CascadeType.ALL, orphanRemoval = true)
private List<DeliveryRecords> deliveryRecords = new ArrayList<>(); // 배송 상세 경로
}
그리고 배송이 만들어지는 시점에 배송 상세 경로(DeliveryRecords)가 생성되어야 한다.
DeliveryRecords가 포함한 정보는 다음과 같다.
public class DeliveryRecords extends BaseEntity {
@Id
@UuidGenerator
@GeneratedValue(generator = "UUID")
@Column(updatable = false, nullable = false)
private UUID deliveryRecordId;
@Column(nullable = false)
private UUID departures; // 출발 허브 id
@Column(nullable = false)
private UUID arrival; // 도착 허브/업체 id
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private DeliveryRecordsStatusEnum status = DeliveryRecordsStatusEnum.WAIT; // 진행 상태
@Column(nullable = false)
private Integer sequence; // 시퀀스 순서
@Column(nullable = false)
private Duration estimatedTime; // 예상 소요 시간
@Column(nullable = false)
private BigDecimal estimatedDist; // 예상 소요 거리
private Duration actualTime; // 실제 소요 시간
private BigDecimal actualDist; // 실제 소요 거리
@ManyToOne
@JoinColumn(name = "delivery_id", nullable = false)
private Deliveries delivery;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deliverer_id")
private Deliverers deliverer; // 배송 담당자 id
}
배송이 만들어지는 시점에 출발 허브로부터 업체까지 예상되는 최단거리를 탐색해서 미리 저장해두고 각 시퀀스가 도래하는 시점에 배송 담당자를 배정할 것이다.
이 때, 허브-허브는 인접 허브끼리 연결된 곳도 있고 아닌 곳도 있다.
연결되지 않은 허브는 아무리 가까워도 바로 이동이 불가하다.
업체는 하나의 허브에 속해있고, 이 업체가 속해있는 허브를 마지막 종착 허브라고 생각하여 허브-허브간 이동 경로를 계산해야 한다.
허브-허브간 경로는 허브 서비스에서 레디스에 캐싱해두고, 배송 서비스에는 이 캐싱된 정보를 가져와서 최단경로를 계산해 배송 상세 경로와 배송을 생성하면 된다.
업체와 가장 가
레디스에 캐싱될 정보는 다음과 같다.
package com.sparta.delivery.dto;
import java.time.Duration;
import java.util.UUID;
public record HubRoute(
UUID hubRouteId,
UUID departureHubId,
UUID arrivalHubId,
double estimatedDistance, // 예상 소요 거리
Duration estimateTime // 에상 소요 시간
) {
}
먼저 레디스에 캐싱된 허브 간 경로를 가져온다.
public List<HubRoute> getHubRoutes() {
try {
String hubRoutesJson = redisTemplate.opsForValue().get("hub_routes");
if (Objects.isNull(hubRoutesJson)) {
return null;
}
return objectMapper.readValue(hubRoutesJson, new TypeReference<>() {});
} catch (Exception e) {
throw new RuntimeException("레디스 조회 실패", e);
}
}
그리고 경로를 바탕으로 그래프를 만든다.
private Map<UUID, List<HubRoute>> buildGraph(List<HubRoute> hubRoutes) {
Map<UUID, List<HubRoute>> graph = new HashMap<>();
for (HubRoute route : hubRoutes) {
graph.computeIfAbsent(route.departureHubId(), k -> new ArrayList<>()).add(route);
}
return graph;
}
만들어진 그래프로 다익스트라를 돌린다.
PriorityQueue<Node> queue = new PriorityQueue<>(Comparator.comparing(Node::totalTime));
Map<UUID, Duration> shortestTimes = new HashMap<>();
Map<UUID, UUID> previousNodes = new HashMap<>();
우선순위 큐를 이용하여 현재까지 계산된 최단 시간을 기준으로 노드를 자동 정렬시킨다.
Node에는 현재 탐색 중인 hubId와 누적 시간(totalTime)이 저장된다.
shortestTimes
Duration.ofDays(Long.MAX_VALUE)로 최대 시간을 설정해준다.previousNodes
queue.add(new Node(startHubId, Duration.ZERO));
shortestTimes.put(startHubId, Duration.ZERO);
시작 허브를 큐에 추가하고 누적 시간을 0으로 설정한다.
while (!queue.isEmpty()) {
Node currentNode = queue.poll();
UUID currentHubId = currentNode.hubId();
큐에서 가장 작은 누적 시간을 가진 노드를 꺼낸다.
if (currentHubId.equals(endHubId)) {
return reconstructPath(previousNodes, endHubId);
}
현재 노드가 종착 허브(endHubId)와 같다면 탐색을 종료한다.
reconstructPath를 호출해 최단 경로를 복원하고 반환한다.
도달하지 못했다면 인접 노드를 탐색한다.
List<HubRoute> neighbors = graph.getOrDefault(currentHubId, Collections.emptyList());
for (HubRoute route : neighbors) {
UUID neighborHubId = route.arrivalHubId();
Duration newTime = currentNode.totalTime().plus(route.estimateTime());
현재 허브에 연결된 인접 노드를 가져온다.
각 경로에 대해서 누적 시간+현재 경로의 소요 시간을 더한다.
if (newTime.compareTo(shortestTimes.getOrDefault(neighborHubId, Duration.ofDays(Long.MAX_VALUE))) < 0) {
shortestTimes.put(neighborHubId, newTime);
previousNodes.put(neighborHubId, currentHubId);
queue.add(new Node(neighborHubId, newTime));
}
현재 경로를 통해 인접 허브로 가는 시간이 최단 시간보다 짧다면
return Collections.emptyList();
큐를 다 돌았는데도 도달하지 못하면 빈 리스트를 반환한다.
근데 어쨌든 모두 연결되어 있는 허브 간 경로라 이럴 경우는 없을 것이다.
reconstructPath의 내용은 다음과 같다.
private List<UUID> reconstructPath(Map<UUID, UUID> previousNodes, UUID endHubId) {
List<UUID> path = new LinkedList<>();
for (UUID arrival = endHubId; arrival != null; arrival = previousNodes.get(arrival)) {
path.add(0, arrival);
}
return path;
}
endHubId로부터 시작해 직전 노드를 따라가면서 리스트의 맨 앞에 추가한다.
previousNodes.get(at)를 반복적으로 호출하면서 이전 노드를 추적하고 null에 도달하면 경로 복원이 완료된다.
최종 코드
public class PathService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
// Redis에서 허브 경로 데이터 가져오기
public List<HubRoute> getHubRoutes() {
try {
String hubRoutesJson = redisTemplate.opsForValue().get("hub_routes");
if (Objects.isNull(hubRoutesJson)) {
return null;
}
return objectMapper.readValue(hubRoutesJson, new TypeReference<>() {});
} catch (Exception e) {
throw new RuntimeException("레디스 조회 실패", e);
}
}
// 시작 허브부터 종착 허브까지 최단 경로 찾기
public List<UUID> findShortestPath(List<HubRoute> hubRoutes, UUID startHubId, UUID endHubId) {
// 그래프 구성
Map<UUID, List<HubRoute>> graph = buildGraph(hubRoutes);
return dijkstra(graph, startHubId, endHubId);
}
private Map<UUID, List<HubRoute>> buildGraph(List<HubRoute> hubRoutes) {
Map<UUID, List<HubRoute>> graph = new HashMap<>();
for (HubRoute route : hubRoutes) {
graph.computeIfAbsent(route.departureHubId(), k -> new ArrayList<>()).add(route);
}
return graph;
}
private List<UUID> dijkstra(Map<UUID, List<HubRoute>> graph, UUID startHubId, UUID endHubId) {
// 우선순위 큐 (최단 시간 기준)
PriorityQueue<Node> queue = new PriorityQueue<>(Comparator.comparing(Node::totalTime));
Map<UUID, Duration> shortestTimes = new HashMap<>();
Map<UUID, UUID> previousNodes = new HashMap<>();
// 초기화
queue.add(new Node(startHubId, Duration.ZERO));
shortestTimes.put(startHubId, Duration.ZERO);
while (!queue.isEmpty()) {
Node currentNode = queue.poll();
UUID currentHubId = currentNode.hubId();
// 종착 허브에 도달하면 경로 복원
if (currentHubId.equals(endHubId)) {
return reconstructPath(previousNodes, endHubId);
}
// 인접 허브 탐색
List<HubRoute> neighbors = graph.getOrDefault(currentHubId, Collections.emptyList());
for (HubRoute route : neighbors) {
UUID neighborHubId = route.arrivalHubId();
Duration newTime = currentNode.totalTime().plus(route.estimateTime());
if (newTime.compareTo(shortestTimes.getOrDefault(neighborHubId, Duration.ofDays(Long.MAX_VALUE))) < 0) {
shortestTimes.put(neighborHubId, newTime);
previousNodes.put(neighborHubId, currentHubId);
queue.add(new Node(neighborHubId, newTime));
}
}
}
return Collections.emptyList();
}
private List<UUID> reconstructPath(Map<UUID, UUID> previousNodes, UUID endHubId) {
List<UUID> path = new LinkedList<>();
for (UUID arrival = endHubId; arrival != null; arrival = previousNodes.get(arrival)) {
path.add(0, arrival);
}
return path;
}
private static class Node {
private final UUID hubId;
private final Duration totalTime;
public Node(UUID hubId, Duration totalTime) {
this.hubId = hubId;
this.totalTime = totalTime;
}
public UUID hubId() {
return hubId;
}
public Duration totalTime() {
return totalTime;
}
}
}
다른 서비스와 연동하는 부분은 추후에 구현할거고, 일단은 임시 데이터로 때워뒀다.
public ApiResponse createDelivery(CreateDeliveryRequest request) {
// 사용자 권한 및 유효성 체크, 수신인 조회
String recipient = "tempRecipient";
String recipientSlackAccount = "tempRecipientSlackAccount";
// 업체 조회
String companyAddress = "tempCompanyAdress";
Point companyLocation = new Point(BigDecimal.valueOf(127.1058342), BigDecimal.valueOf(37.359708));
// 업체가 소속된 허브를 종착 허브로 설정
// 배송 생성
Deliveries delivery = Deliveries.create(
request.orderId(),
request.sourceHubId(),
request.destinationId(),
companyAddress,
recipient,
recipientSlackAccount
);
// Redis에서 허브 경로 데이터 가져오기
List<HubRoute> hubRoutes = pathService.getHubRoutes();
// if(hubRoutes == null) {
// // 경로 데이터 없을 경우 DB에서 조회 필요
// }
// 배송 상세 경로 생성
List<UUID> paths = pathService.findShortestPath(hubRoutes, request.sourceHubId(), request.destinationId());
List<DeliveryRecords> deliveryRecordsList = new ArrayList<>();
for (int i = 0; i < paths.size() - 1; i ++) {
UUID departureHubId = paths.get(i);
UUID arrivalHubId = paths.get(i + 1);
HubRoute hubRoute = pathService.findHubRoute(hubRoutes, departureHubId, arrivalHubId);
DeliveryRecords deliverRecord = DeliveryRecords.create(
departureHubId,
arrivalHubId,
i + 1,
hubRoute.estimateTime(),
BigDecimal.valueOf(hubRoute.estimatedDistance()),
delivery
);
deliveryRecordsList.add(deliverRecord);
}
// 마지막 허브 위치 조회
Point lastHubLocation = new Point(BigDecimal.valueOf(126.977969), BigDecimal.valueOf(37.566535));
// 업체까지의 배송 경로 레코드 생성
KakaoRouteResponse response = kakaoMapService.getRoute(lastHubLocation, companyLocation);
if(response != null && response.routes().length >0) {
BigDecimal distance = response.routes()[0].summary().distance();
BigDecimal duration = response.routes()[0].summary().duration();
DeliveryRecords finalRecord = DeliveryRecords.create(
paths.get(paths.size() - 1),
request.destinationId(),
deliveryRecordsList.size() + 1,
Duration.ofMillis(duration.longValue()),
distance,
delivery
);
deliveryRecordsList.add(finalRecord);
}
// 시퀀스 업데이트
Deliveries updatedDelivery = Deliveries.updateSequence(delivery, 0, deliveryRecordsList.size());
// 배송 저장
deliveryJpaRepository.save(updatedDelivery);
// 배송 상세 경로 저장
deliveryRecordsJpaRepository.saveAll(deliveryRecordsList);
// 슬랙 메시지 전송
// 반환
return new ApiResponse<>(200, "배송 생성 성공", null);
}
어질어질하다.
종착 허브-업체까지의 예상 소요 시간과 거리는 카카오 맵 API를 이용할 것이다.
public class KakaoMapService {
@Value("${kakao.api.key}")
private String KAKAO_API_KEY;
private static final String DIRECTIONS_URL = "https://apis-navi.kakaomobility.com/v1/directions";
public KakaoRouteResponse getRoute(Point origin, Point destination) {
// 요청 URL 생성
String url = UriComponentsBuilder.fromUriString(DIRECTIONS_URL)
.queryParam("origin", origin.getLongitude() + "," + origin.getLatitude())
.queryParam("destination", destination.getLongitude() + "," + destination.getLatitude())
.queryParam("priority", "SHORTEST")
.toUriString();
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "KakaoAK " + KAKAO_API_KEY);
// API 호출
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<KakaoRouteResponse> response = restTemplate.exchange(
url,
HttpMethod.GET,
new org.springframework.http.HttpEntity<>(headers),
KakaoRouteResponse.class
);
return response.getBody();
}
}
단순히 요청을 보내고 응답을 받는다..
너무 길어서 잘라보려고 한다.
DeliveryService
public class DeliveryService {
private final PathService pathService;
private final KakaoMapService kakaoMapService;
private final DeliveriesJpaRepository deliveryJpaRepository;
private final DeliveryRecordsJpaRepository deliveryRecordsJpaRepository;
private static final Logger logger = LoggerFactory.getLogger(DeliveryService.class);
// 배송 생성
public ApiResponse createDelivery(CreateDeliveryRequest request) {
try {
Deliveries delivery = createDeliveryEntity(request);
// Redis에서 경로 데이터 가져오기
List<HubRoute> hubRoutes = pathService.getHubRoutes();
// 최단 경로 생성
List<DeliveryRecords> deliveryRecordsList = createDeliveryRecords(request, hubRoutes, delivery);
// 마지막 허브에서 업체까지의 경로 추가
addFinalDeliveryRecord(deliveryRecordsList, delivery);
// 배송 데이터 저장
saveDelivery(delivery, deliveryRecordsList);
return new ApiResponse<>(200, "배송 생성 성공", null);
} catch (Exception e) {
logger.error("Error occurred while creating delivery: {}", e.getMessage(), e);
return new ApiResponse<>(500, "배송 생성 실패", null);
}
}
private Deliveries createDeliveryEntity(CreateDeliveryRequest request) {
return Deliveries.create(
request.orderId(),
request.sourceHubId(),
request.destinationId(),
"tempCompanyAddress",
"tempRecipient",
"tempRecipientSlackAccount"
);
}
private List<DeliveryRecords> createDeliveryRecords(CreateDeliveryRequest request, List<HubRoute> hubRoutes, Deliveries delivery) {
List<UUID> paths = pathService.findShortestPath(hubRoutes, request.sourceHubId(), request.destinationId());
List<DeliveryRecords> deliveryRecordsList = new ArrayList<>();
for (int i = 0; i < paths.size() - 1; i++) {
UUID departureHubId = paths.get(i);
UUID arrivalHubId = paths.get(i + 1);
HubRoute hubRoute = pathService.findHubRoute(hubRoutes, departureHubId, arrivalHubId);
DeliveryRecords record = DeliveryRecords.create(
departureHubId,
arrivalHubId,
i + 1,
hubRoute.estimateTime(),
BigDecimal.valueOf(hubRoute.estimatedDistance()),
delivery
);
deliveryRecordsList.add(record);
}
return deliveryRecordsList;
}
private void addFinalDeliveryRecord(List<DeliveryRecords> deliveryRecordsList, Deliveries delivery) {
Point lastHubLocation = new Point(BigDecimal.valueOf(126.977969), BigDecimal.valueOf(37.566535));
Point companyLocation = new Point(BigDecimal.valueOf(127.1058342), BigDecimal.valueOf(37.359708));
kakaoMapService.getRouteAsync(lastHubLocation, companyLocation)
.thenAccept(response -> {
if (response != null && response.routes().length > 0) {
BigDecimal distance = response.routes()[0].summary().distance();
BigDecimal duration = response.routes()[0].summary().duration();
DeliveryRecords finalRecord = DeliveryRecords.create(
deliveryRecordsList.get(deliveryRecordsList.size() - 1).getArrival(),
delivery.getCompanyId(),
deliveryRecordsList.size() + 1,
Duration.ofMillis(duration.longValue()),
distance,
delivery
);
deliveryRecordsList.add(finalRecord);
} else {
logger.warn("유효한 경로가 없음");
}
}).exceptionally(e -> {
logger.error("addFinalDeliveryRecord failed: {}", e.getMessage(), e);
return null;
});
}
private void saveDelivery(Deliveries delivery, List<DeliveryRecords> deliveryRecordsList) {
delivery.setTotalSequence(deliveryRecordsList.size());
delivery.setCurrentSeq(0);
deliveryJpaRepository.save(delivery);
deliveryRecordsJpaRepository.saveAll(deliveryRecordsList);
}
}
카카오 맵에서 응답을 받는 방식을 비동기로 바꿔보았다.
비동기 방식을 적용하면 다음과 같은 이점이 있다.
public class KakaoMapService {
@Value("${kakao.api.key}")
private String kakaoApiKey;
@Async
public CompletableFuture<KakaoRouteResponse> getRouteAsync(Point origin, Point destination) {
try {
String url = UriComponentsBuilder.newInstance()
.scheme("https")
.host("apis-navi.kakaomobility.com")
.path("/v1/directions")
.queryParam("origin", origin.getLongitude() + "," + origin.getLatitude())
.queryParam("destination", destination.getLongitude() + "," + destination.getLatitude())
.queryParam("priority", "SHORTEST")
.build()
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "KakaoAK " + kakaoApiKey);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<KakaoRouteResponse> response = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
KakaoRouteResponse.class
);
return CompletableFuture.completedFuture(response.getBody());
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
}
Spring의 비동기 작업을 위한 어노테이션
메서드 호출 시 새로운 스레드에서 작업을 실행하며, 호출자는 CompletableFuture 객체를 즉시 반환받음.
비동기 작업을 쉽게 관리하고 조합할 수 있도록 설계됨
비동기 방식으로 작업을 실행하고, 작업 완료 시의 후속 작업을 정의하거나 작업 완료 시 결과를 처리하는 방식을 제공함
주요 기능 및 특징
비동기 작업 실행
CompletableFuture.supplyAsync(() -> {
// 비동기 작업
return "Result";
});
후속 작업 연결
| 메서드 | 설명 |
|---|---|
supplyAsync | 결과를 반환하는 비동기 작업 실행 |
runAsync | 결과를 반환하지 않는 비동기 작업 실행 |
thenApply | 작업 완료 후 결과를 변환하는 작업 추가 |
thenAccept | 작업 완료 후 결과를 소비하는 작업 추가 |
thenRun | 작업 완료 후 결과와 무관하게 실행되는 작업 추가 |
thenCombine | 두 개의 비동기 작업 결과를 조합 |
exceptionally | 예외가 발생했을 때 기본값으로 대체 |
whenComplete | 작업이 완료되면 성공/실패 여부와 관계없이 실행 |
allOf | 여러 CompletableFuture 작업이 모두 완료될 때 실행 |
anyOf | 여러 CompletableFuture 작업 중 하나라도 완료되면 실행 |
작업 조합
작업 완료 시 처리
결과 대기
장점
단점