개발 일지 (AI 일정 자동 배치)
AI 일정 자동 배치 도입
- 우리 서비스는 이동 약자를 위한 여행 플래너이기 때문에, 여행 일정을 짜는 것도 고려를 해야한다.
- 그리고 처음에 관광지들을 일정에다가 보관하고 나면, 사용자가 일정 안에서 관광지들을 날짜별로 순서를 정해서 여행 코스를 커스터마이징할 수 있다.
- 사용자가 직접 일정을 관리하는 것도 가능하지만, 사용자 입장에서는 알아서 일정을 동선에 효율적으로 짜주는 기능도 있으면 매우 유용하게 사용할 수 있는 기능이라고 판단했다.
AI 일정 자동 배치에 고려해야할 점
그렇다면 AI에게 일정 자동 배치를 맡길 때 어떤 것들을 고려해야할까?
- 관광지를 주어진 날짜 중 어떤 날짜에 배치해야할까
- 각 날짜 안에서, 동선은 어떤 것을 기준으로 구성해야할까
- AI가 내놓은 결과를 진짜로 믿을만할까
위의 고려 사항들을 하나씩 짚어보도록 하자.
관광지를 어떤 날짜에 배치해야할까
- 일단 이 문제를 짚기 전에, 우리 서비스의 기능에는 "각 날짜별로 시작/끝 관광지 정하기" 기능이 있다.
- 이 기능의 존재 이유는, 여행자가 AI로 자동 배치를 원해도, 각 날짜별로 시작/끝 관광지를 정해놓은 상태로 추천을 받고 싶을 수 있기 때문이다.
- 예를 들어서, A 숙소를 시작/끝 관광지로 잡고 싶다면, 그걸 미리 지정을 해두고 일정 자동 배치 기능을 실행하는 것이다.
- 그렇게 된다면, 지금 일정에는 두 가지 종류의 관광지가 있다.
- 날짜가 안 정해진 관광지
- 날짜가 정해진 관광지
- 우리는 여행 일정에서 날짜의 개수를 알 수 있다. 2박 3일이라면 날짜의 개수는 3개이다.
- 그말인 즉슨, 관광지들을 묶을 그룹의 개수가 정해져 있기 때문에, 군집 알고리즘을 이용해서 관광지들을 그루핑할 수 있겠다고 판단했다.
그럼 군집 알고리즘은 어떻게 적용하지?
1. 각 날짜별 기준 관광지 정하기
- 군집 알고리즘을 적용하기 위해서는, 각 날짜별로 기준 관광지가 필요하다.
- 그 기준 관광지를 중심으로 주변의 관광지들을 해당 날짜에 박아야하기 때문이다.
- 만약에 사용자가 고정을 해둔 관광지가 있다면, 그 관광지를 기준 관광지로 삼으면 된다.
- 만약에 사용자가 해당 날짜에 고정해둔 관광지가 없다면, 서버에서, 노드 ID를 기반으로 해당 날짜에 관광지 하나를 기준 관광지로 정의한다.
/**
* 날짜별 seed 노드를 선택한다.
*
* 선택 기준:
* 1. 좌표가 있는 노드를 우선 사용한다.
* 2. 첫 seed는 id가 가장 작은 노드로 안정적으로 선택한다.
* 3. 이후 seed는 기존 seed들과 가장 멀리 떨어진 노드를 선택한다.
* 4. 좌표가 부족하면 id 순서로 부족한 seed를 채운다.
*/
private List<Node> selectSeedNodes(
List<Node> nodes,
int seedCount
) {
if (seedCount <= 0) {
return List.of();
}
List<Node> coordinateNodes = nodes.stream()
.filter(this::hasCoordinate)
.sorted(Comparator.comparing(Node::getId))
.toList();
List<Node> seeds = new ArrayList<>();
if (!coordinateNodes.isEmpty()) {
seeds.add(coordinateNodes.get(0));
while (seeds.size() < seedCount && seeds.size() < coordinateNodes.size()) {
Node nextSeed = coordinateNodes.stream()
.filter(node -> !seeds.contains(node))
.max(Comparator
.comparingDouble((Node node) -> distanceToNearestSeed(node, seeds))
.thenComparing(Node::getId, Comparator.reverseOrder()))
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_ROUTE_RECOMMENDATION_REQUEST));
seeds.add(nextSeed);
}
}
if (seeds.size() < seedCount) {
Set<Long> seedIds = seeds.stream()
.map(Node::getId)
.collect(Collectors.toSet());
List<Node> fallbackSeeds = nodes.stream()
.filter(node -> !seedIds.contains(node.getId()))
.sorted(Comparator.comparing(Node::getId))
.limit(seedCount - seeds.size())
.toList();
seeds.addAll(fallbackSeeds);
}
return seeds;
}
2. 각 날짜별로 나머지 관광지 노드들을 삽입하기
- 기준 관광지가 아닌 나머지 관광지들은, 순차적으로 순회하면서 점수를 비교해서, 점수가 가장 낮은 날짜 그룹에 추가시킨다.
- 점수의 기준은, 날짜 그룹과의 거리이다.
- 여기서 날짜 그룹과의 거리라고 함은, 해당 날짜에 이 노드들 배치시켰을 때, 가장 가까이 있는 노드와의 거리이다.
rivate LocalDate findBestClusterDate(
Node node,
List<LocalDate> scheduleDates,
Map<LocalDate, List<Node>> plannedNodesByDate
) {
return scheduleDates.stream()
.min(Comparator
.comparing((LocalDate date) -> calculateClusterAssignmentScore(
node,
date,
plannedNodesByDate
))
.thenComparing(date -> plannedNodesByDate.getOrDefault(date, List.of()).size())
.thenComparing(date -> date))
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_ROUTE_RECOMMENDATION_REQUEST));
}
/**
* 특정 날짜 군집에 node를 넣었을 때의 점수.
*
* 날짜별 개수를 균등하게 맞추기보다,
* 이미 형성된 날짜 그룹과의 거리만 기준으로 판단한다.
*/
private double calculateClusterAssignmentScore(
Node node,
LocalDate date,
Map<LocalDate, List<Node>> plannedNodesByDate
) {
List<Node> dateNodes = plannedNodesByDate.getOrDefault(date, List.of());
if (dateNodes.isEmpty()) {
return Double.MAX_VALUE;
}
return distanceToDateGroup(node, dateNodes);
}
예시를 통해 살펴보자.
3일 여행이고, fixed 노드와 seed 노드를 배치한 뒤 현재 상태가 다음과 같다고 하자.
Day1: [A]
Day2: [D]
Day3: [G]
남은 노드: B, C, E, F
먼저 B를 배치한다.
B와 Day1 그룹의 거리: 2km
B와 Day2 그룹의 거리: 9km
B와 Day3 그룹의 거리: 15km
=> B는 Day1에 배치
결과는 다음과 같다.
Day1: [A, B]
Day2: [D]
Day3: [G]
다음으로 C를 배치한다.
C와 Day1 그룹의 거리: 1km
C와 Day2 그룹의 거리: 8km
C와 Day3 그룹의 거리: 12km
=> C는 Day1에 배치
결과는 다음과 같다.
Day1: [A, B, C]
Day2: [D]
Day3: [G]
다음으로 E를 배치한다.
E와 Day1 그룹의 거리: 10km
E와 Day2 그룹의 거리: 2km
E와 Day3 그룹의 거리: 6km
=> E는 Day2에 배치
결과는 다음과 같다.
Day1: [A, B, C]
Day2: [D, E]
Day3: [G]
마지막으로 F를 배치한다.
F와 Day1 그룹의 거리: 14km
F와 Day2 그룹의 거리: 5km
F와 Day3 그룹의 거리: 2km
=> F는 Day3에 배치
최종 날짜 그룹은 다음과 같다.
Day1: [A, B, C]
Day2: [D, E]
Day3: [G, F]
이후 서버는 각 날짜 그룹에서 시작 노드와 종료 노드를 결정하고, AI에게 각 날짜 안의 방문 순서 최적화를 맡긴다.
각 날짜별로 start와 end를 정해보자.
이제 각 날짜별로 노드들이 다 배치가 된 상황이다. 그렇다면 이제 해야할 것은, 각 날짜별로, start/end를 정하는 것이다.
1. 사용자가 fixed start/end를 지정했으면 그 값을 우선한다.
2. fixed start만 있으면, start에서 가장 먼 노드를 end로 둔다.
3. fixed end만 있으면, end에서 가장 먼 노드를 start로 둔다.
4. fixed가 없으면, 날짜 그룹 안에서 가장 먼 두 노드를 start/end로 둔다.
private Map<LocalDate, DayBoundary> determineBoundariesByFarthestNodes(
List<LocalDate> scheduleDates,
Map<LocalDate, List<Node>> plannedNodesByDate,
Map<LocalDate, AutoArrangeRequest.DayCondition> conditionMap
) {
Map<LocalDate, DayBoundary> boundaryMap = new TreeMap<>();
Node previousEndNode = null;
for (LocalDate date : scheduleDates) {
List<Node> dayNodes = plannedNodesByDate.getOrDefault(date, List.of());
AutoArrangeRequest.DayCondition condition = conditionMap.get(date);
Long fixedStartNodeId = condition != null ? condition.getStartNodeId() : null;
Long fixedEndNodeId = condition != null ? condition.getEndNodeId() : null;
BoundaryPair generatedBoundary = determineBoundaryPair(
dayNodes,
fixedStartNodeId,
fixedEndNodeId,
previousEndNode
);
DayBoundary boundary = new DayBoundary(
date,
fixedStartNodeId,
fixedEndNodeId,
generatedBoundary.startNodeId(),
generatedBoundary.endNodeId()
);
boundaryMap.put(date, boundary);
Long effectiveEndNodeId = boundary.effectiveEndNodeId();
previousEndNode = findNodeById(dayNodes, effectiveEndNodeId);
}
return boundaryMap;
}
이 메서드에서 사용하는 값은 크게 세 가지다.
plannedNodesByDate
= 날짜별로 이미 배치된 관광지 그룹
conditionMap
= 사용자가 직접 지정한 fixed start/end 조건
boundaryMap
= 최종적으로 AI에게 전달할 날짜별 start/end 정보
즉, 날짜별 관광지 그룹과 사용자의 fixed 조건을 바탕으로 각 날짜의 boundary를 만든다.
여기서 중요한 점은, 서로 먼 거리에 있는 노드를 start/end로 정하긴 하는데, 어떤 걸 start로 해야할지, end로 해야할지 모른다는 점이다.
가장 먼 두 노드의 방향 정하기
가장 먼 두 노드를 찾았다고 해서 바로 어느 쪽이 start이고 어느 쪽이 end인지 알 수 있는 것은 아니다.
예를 들어 A와 C가 가장 멀다고 해보자.
A ---- B ---- C
이때 가능한 방향은 두 가지다.
A -> B -> C
C -> B -> A
둘 다 하루 안에서는 이동 거리 차이가 크지 않을 수 있다.
그래서 서버는 이전 날짜의 종료 지점을 참고해 방향을 정한다.
private BoundaryNodePair orientFarthestPair(
BoundaryNodePair pair,
Node previousEndNode
) {
Node a = pair.startNode();
Node b = pair.endNode();
if (previousEndNode != null
&& isFiniteDistance(previousEndNode, a)
&& isFiniteDistance(previousEndNode, b)) {
double distanceToA = distanceKm(previousEndNode, a);
double distanceToB = distanceKm(previousEndNode, b);
if (distanceToB < distanceToA) {
return new BoundaryNodePair(b, a);
}
if (distanceToA < distanceToB) {
return new BoundaryNodePair(a, b);
}
}
if (a.getId() <= b.getId()) {
return new BoundaryNodePair(a, b);
}
return new BoundaryNodePair(b, a);
}
이전 날짜의 종료 지점이 있다면, 그 지점과 더 가까운 쪽을 다음 날짜의 시작점으로 둔다.
예를 들어 1일차가 C에서 끝났고, 2일차의 가장 먼 두 노드가 D와 F라고 하자.
Day1 end = C
Day2 후보 양 끝:
D ---- E ---- F
만약 C가 D와 더 가깝다면 2일차는 D에서 시작한다.
C와 D가 가까움
C와 F는 멂
=> Day2: D -> ... -> F
이렇게 하면 날짜 간 이동 흐름이 조금 더 자연스러워진다.
만약 이전 날짜 종료 지점이 없거나 거리 계산이 불가능하다면, nodeId가 작은 쪽을 start로 둔다.
이는 결과의 재현성을 위한 선택이다.
plannedNodesByDate
Day1: [A, B, C]
Day2: [D, E]
Day3: [F, G, H]
↓
determineBoundariesByFarthestNodes()
↓
boundaryMap
Day1: start=A, end=C
Day2: start=D, end=E
Day3: start=F, end=H
AI에게 전달할 요청 데이터 만들기
이제 서버는 날짜별 그룹과 start/end 조건을 모두 결정했다.
다음 단계는 이 정보를 AI가 이해할 수 있는 요청 형식으로 변환하는 것이다.
AI 요청에는 다음 정보가 들어간다.
1. scheduleId
2. 목표 설명
3. contentTypeId 의미
4. 제약 조건
5. 날짜별 노드 목록
6. 날짜별 startNodeId / endNodeId
7. 날짜별 edge 후보
요청 생성은 buildAiRequest()에서 시작된다.
private RouteRecommendAiRequest buildAiRequest(
Schedule schedule,
List<Node> nodes,
List<Edge> edges,
Map<LocalDate, DayBoundary> boundaryMap
) {
Map<Integer, String> contentTypeMap = Map.of(
12, "관광지",
14, "문화시설",
15, "축제공연행사",
25, "여행코스",
28, "레포츠",
32, "숙박",
38, "쇼핑",
39, "음식점"
);
RouteRecommendAiRequest.Constraints constraints =
new RouteRecommendAiRequest.Constraints(
true,
true,
true,
true,
true,
true
);
List<RouteRecommendAiRequest.DayRequest> dayRequests =
buildDayRequests(schedule, nodes, edges, boundaryMap);
return new RouteRecommendAiRequest(
schedule.getId(),
"서버가 확정한 날짜별 관광지 그룹과 시작/종료 조건을 지키면서 이동 시간을 최소화하고 관광지 타입이 골고루 섞이도록 방문 순서를 추천",
contentTypeMap,
constraints,
dayRequests
);
}
여기서 contentTypeMap은 AI가 관광지 유형을 이해할 수 있도록 전달하는 값이다.
12 = 관광지
14 = 문화시설
32 = 숙박
39 = 음식점
이 값을 전달하는 이유는 AI가 단순히 이동 시간만 보는 것이 아니라, 같은 유형이 너무 연속되지 않도록 참고할 수 있게 하기 위해서다.
예를 들어 음식점만 연속으로 배치되는 것보다, 관광지와 음식점, 문화시설이 적절히 섞이는 편이 더 자연스러운 여행 일정이 될 수 있다.
날짜별 요청 만들기
실제 날짜별 요청은 buildDayRequests()에서 만든다.
private List<RouteRecommendAiRequest.DayRequest> buildDayRequests(
Schedule schedule,
List<Node> nodes,
List<Edge> edges,
Map<LocalDate, DayBoundary> boundaryMap
) {
Map<LocalDate, List<Node>> nodesByDate = nodes.stream()
.filter(node -> node.getVisitDate() != null)
.collect(Collectors.groupingBy(
Node::getVisitDate,
TreeMap::new,
Collectors.toList()
));
List<RouteRecommendAiRequest.DayRequest> dayRequests = new ArrayList<>();
LocalDate current = schedule.getStartDate();
while (!current.isAfter(schedule.getEndDate())) {
DayBoundary boundary = boundaryMap.get(current);
Long startNodeId = boundary != null ? boundary.effectiveStartNodeId() : null;
Long endNodeId = boundary != null ? boundary.effectiveEndNodeId() : null;
List<Node> dayNodes = nodesByDate.getOrDefault(current, List.of()).stream()
.sorted(Comparator
.comparing(Node::getVisitOrder)
.thenComparing(Node::getId))
.toList();
Set<Long> dayNodeIds = dayNodes.stream()
.map(Node::getId)
.collect(Collectors.toSet());
List<RouteRecommendAiRequest.NodeRequest> nodeRequests = dayNodes.stream()
.map(node -> new RouteRecommendAiRequest.NodeRequest(
node.getId(),
schedule.getId(),
node.getAttraction() != null ? node.getAttraction().getId() : null,
node.getVisitOrder(),
node.getVisitDate(),
node.getAttraction() != null
? parseContentTypeId(node.getAttraction().getContentTypeId())
: null
))
.toList();
List<RouteRecommendAiRequest.EdgeRequest> edgeRequests = edges.stream()
.filter(edge ->
dayNodeIds.contains(edge.getFromNode().getId())
&& dayNodeIds.contains(edge.getToNode().getId()))
.map(edge -> new RouteRecommendAiRequest.EdgeRequest(
edge.getId(),
edge.getFromNode().getId(),
edge.getToNode().getId(),
edge.getEstimatedTimeMinutes(),
edge.getDistanceMeters()
))
.toList();
boolean startFixed = boundary != null && boundary.fixedStartNodeId() != null;
boolean endFixed = boundary != null && boundary.fixedEndNodeId() != null;
dayRequests.add(new RouteRecommendAiRequest.DayRequest(
current,
startNodeId,
endNodeId,
startFixed,
endFixed,
nodeRequests,
edgeRequests
));
current = current.plusDays(1);
}
return dayRequests;
}
이 메서드는 일정 시작일부터 종료일까지 날짜를 순회하면서, 각 날짜에 해당하는 노드와 edge 정보를 AI 요청 형식으로 바꾼다.
핵심은 startNodeId와 endNodeId다.
Long startNodeId = boundary != null ? boundary.effectiveStartNodeId() : null;
Long endNodeId = boundary != null ? boundary.effectiveEndNodeId() : null;
이 값들은 앞에서 결정한 날짜별 시작/종료 조건이다.
AI는 이 조건을 지키면서 각 날짜 안의 방문 순서를 추천해야 한다.
각 날짜 안에서, 동선은 어떤 것을 기준으로 구성할까
앞 단계에서 서버는 관광지들을 날짜별 그룹으로 나누고, 각 날짜의 시작 지점과 종료 지점을 정했다.
예를 들어 다음과 같은 상태가 만들어졌다고 하자.
Day1: [A, B, C]
start = A
end = C
Day2: [D, E, F]
start = D
end = F
이제 서버가 해야 할 일은 날짜 그룹을 확정하는 것까지다.
그다음부터는 AI가 각 날짜 안에서 방문 순서를 추천한다.
다만 여기서 AI에게 모든 것을 자유롭게 맡기지는 않았다.
AI가 할 수 있는 일은 각 날짜 안에서 visitOrder를 재배치하는 것으로 제한했다.
AI가 할 수 있는 것
- Day1 안에서 A, B, C의 방문 순서를 정한다.
- Day2 안에서 D, E, F의 방문 순서를 정한다.
- 이동 시간이 짧은 순서를 우선 고려한다.
- 관광지 유형이 너무 한쪽으로 몰리지 않도록 고려한다.
AI가 하면 안 되는 것
- Day1의 노드를 Day2로 옮긴다.
- 없는 nodeId를 만든다.
- 기존 nodeId를 누락한다.
- startNodeId/endNodeId 조건을 무시한다.
즉, AI는 일정 전체를 다시 짜는 것이 아니라, 서버가 확정한 날짜 그룹 안에서 하루 동선을 최적화하는 역할을 맡는다.
이동 시간을 기준으로 순서를 추천한다
각 날짜 안에서 가장 중요한 기준은 이동 시간이다.
예를 들어 Day1에 A, B, C가 있고, start는 A, end는 C라고 하자.
Day1 nodes: A, B, C
start = A
end = C
이 경우 AI는 A에서 시작해서 C로 끝나는 조건을 지키면서, 중간 노드 B를 어디에 배치할지 판단한다.
가능한 순서:
A -> B -> C
A -> C -> B // end 조건 위반
B -> A -> C // start 조건 위반
start/end 조건을 고려하면 가능한 순서는 사실상 A -> B -> C가 된다.
노드가 더 많아지면 경우의 수는 늘어난다.
Day1 nodes: A, B, C, D
start = A
end = D
이 경우 가능한 순서는 다음처럼 여러 개가 될 수 있다.
A -> B -> C -> D
A -> C -> B -> D
AI는 서버가 전달한 edge 정보를 참고해서 어떤 순서의 총 이동 시간이 더 짧은지 판단한다.
A -> B: 10분
B -> C: 8분
C -> D: 12분
총 이동 시간 = 30분
A -> C: 20분
C -> B: 8분
B -> D: 25분
총 이동 시간 = 53분
이 경우에는 A -> B -> C -> D가 더 자연스러운 동선이다.
edge는 AI 판단을 위한 참고 데이터다
AI에게는 각 날짜의 노드 목록뿐 아니라, 해당 날짜 노드들 사이의 edge 정보도 함께 전달한다.
edge에는 다음 정보가 들어 있다.
fromNodeId
toNodeId
estimatedTimeMinutes
distanceMeters
즉, AI는 단순히 관광지 이름만 보고 순서를 정하는 것이 아니라, 서버가 미리 계산해둔 이동 시간과 거리 정보를 참고할 수 있다.
예를 들어 다음과 같은 edge 정보가 있다고 하자.
A -> B: 10분
A -> C: 20분
B -> C: 8분
B -> D: 25분
C -> D: 12분
AI는 이 정보를 바탕으로, start/end 조건을 지키면서 총 이동 시간이 짧은 순서를 선택한다.
start = A
end = D
추천 순서:
A -> B -> C -> D
여기서 중요한 점은 edge가 방향성을 가진다는 것이다.
A -> B edge가 있다고 해서
B -> A 이동이 가능하다는 뜻은 아니다.
따라서 AI 프롬프트에도 다음 규칙을 명시했다.
edge는 방향성이 있다.
fromNodeId=7, toNodeId=8인 edge는 7번 노드에서 8번 노드로 이동할 때만 사용할 수 있다.
반대 방향 이동은 입력에 별도의 edge가 있을 때만 가능하다.
방문 순서상 인접한 두 node 사이에 입력 edge가 없는 순서는 선택하지 마라.
이 규칙을 넣은 이유는 AI가 존재하지 않는 이동 경로를 임의로 선택하지 못하게 하기 위해서다.
관광지 유형도 함께 고려한다
동선 최적화에서 이동 시간만 고려하면 가장 짧은 경로는 만들 수 있다.
하지만 여행 일정은 단순히 최단 거리만으로 좋은 일정이 되지 않는다.
예를 들어 하루 일정이 다음처럼 구성되면 사용자 입장에서는 다소 부자연스러울 수 있다.
음식점 -> 음식점 -> 음식점 -> 관광지
또는 같은 유형의 관광지만 연속되는 것도 아쉬울 수 있다.
문화시설 -> 문화시설 -> 문화시설 -> 음식점
그래서 AI에게는 각 노드의 contentTypeId도 함께 전달한다.
12 = 관광지
14 = 문화시설
15 = 축제공연행사
25 = 여행코스
28 = 레포츠
32 = 숙박
38 = 쇼핑
39 = 음식점
AI는 이 정보를 참고해서 가능한 경우 같은 유형이 연속되는 것을 피하고, 관광지 유형이 적절히 섞이도록 순서를 추천한다.
예를 들어 다음 두 경로가 있다고 하자.
A 관광지 -> B 관광지 -> C 음식점 -> D 문화시설
A 관광지 -> D 문화시설 -> C 음식점 -> B 관광지
두 경로의 이동 시간이 비슷하다면, 두 번째 경로가 더 자연스러운 일정일 수 있다.
관광지, 문화시설, 음식점이 섞여 있기 때문이다.
즉, AI의 목표는 단순히 최단 이동 시간만 찾는 것이 아니다.
1. start/end 조건을 지킨다.
2. 입력된 edge로 연결 가능한 순서만 선택한다.
3. 총 이동 시간을 줄인다.
4. 같은 contentTypeId가 연속되는 것을 가능하면 피한다.
5. 관광지 유형이 적절히 섞이도록 한다.
그래도 최우선은 서버가 정한 조건이다
AI가 이동 시간과 관광지 유형을 고려하더라도, 가장 먼저 지켜야 하는 것은 서버가 정한 조건이다.
예를 들어 서버가 다음 조건을 보냈다고 하자.
Day1 startNodeId = A
Day1 endNodeId = D
그러면 AI는 반드시 A를 첫 번째로, D를 마지막으로 배치해야 한다.
정상적인 응답은 다음과 같다.
A -> B -> C -> D
하지만 다음과 같은 응답은 이동 시간이 짧더라도 사용할 수 없다.
B -> A -> C -> D
A가 첫 번째가 아니기 때문이다.
A -> D -> B -> C
D가 마지막이 아니기 때문이다.
따라서 AI의 동선 최적화는 항상 다음 범위 안에서만 이루어진다.
서버가 확정한 날짜 그룹 안에서
서버가 정한 start/end 조건을 지키면서
입력된 edge로 연결 가능한 순서 중에서
이동 시간과 관광지 유형을 고려해 visitOrder를 추천한다.
AI가 내놓은 결과를 진짜로 믿을만한가
AI에게 요청을 보낸 뒤에는 응답을 그대로 믿지 않는다.
AI는 그럴듯한 응답을 만들 수 있지만, 서비스의 데이터 정합성을 항상 지킨다는 보장은 없다.
따라서 AI 응답은 반드시 서버에서 검증한 뒤 DB에 반영해야 한다.
검증은 validateAiResponse()에서 수행한다.
private void validateAiResponse(
RouteRecommendAiRequest request,
RouteRecommendAiResponse response
) {
if (response == null || response.getDays() == null) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
Set<Long> inputNodeIds = request.getDays().stream()
.flatMap(day -> day.getNodes().stream())
.map(RouteRecommendAiRequest.NodeRequest::getNodeId)
.collect(Collectors.toSet());
List<RouteRecommendAiResponse.NodeResponse> outputNodes = response.getDays().stream()
.filter(day -> day.getNodes() != null)
.flatMap(day -> day.getNodes().stream())
.toList();
Set<Long> outputNodeIds = outputNodes.stream()
.map(RouteRecommendAiResponse.NodeResponse::getNodeId)
.collect(Collectors.toSet());
if (!inputNodeIds.equals(outputNodeIds)) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
validateNodeDates(response);
validateNodeDateNotChanged(request, response);
validateVisitOrders(response);
validateDayConditions(request, response);
rebuildEdgesInResponse(request, response);
}
가장 먼저 확인하는 것은 nodeId다.
AI에게 보낸 nodeId 목록
= AI가 응답한 nodeId 목록
이 조건이 맞아야 한다.
예를 들어 서버가 다음 노드를 보냈다고 하자.
입력 nodeId: 1, 2, 3, 4, 5
AI 응답도 정확히 같은 nodeId를 포함해야 한다.
정상 응답: 1, 2, 3, 4, 5
다음과 같은 응답은 모두 잘못된 응답이다.
없는 nodeId 생성: 1, 2, 3, 4, 99
nodeId 누락: 1, 2, 3, 4
nodeId 중복: 1, 2, 2, 3, 4, 5
AI가 새로운 관광지를 만들어내거나 기존 관광지를 누락하면 실제 일정 데이터가 깨질 수 있다.
그래서 nodeId 검증은 가장 먼저 수행한다.
날짜 변경 검증
AI는 날짜를 바꿀 수 없다.
서버가 이미 날짜별 그룹을 확정했기 때문이다.
예를 들어 서버가 다음과 같이 요청했다면,
Day1: A, B, C
Day2: D, E
AI는 날짜 안에서 순서만 바꿀 수 있다.
정상:
Day1: B, A, C
Day2: E, D
하지만 다음처럼 날짜를 바꾸면 안 된다.
비정상:
Day1: A, C
Day2: B, D, E
B가 Day1에서 Day2로 이동했기 때문이다.
이 검증은 validateNodeDateNotChanged()에서 처리한다.
private void validateNodeDateNotChanged(
RouteRecommendAiRequest request,
RouteRecommendAiResponse response
) {
Map<Long, LocalDate> inputVisitDateByNodeId = request.getDays().stream()
.flatMap(day -> day.getNodes().stream())
.collect(Collectors.toMap(
RouteRecommendAiRequest.NodeRequest::getNodeId,
RouteRecommendAiRequest.NodeRequest::getVisitDate
));
for (RouteRecommendAiResponse.DayResponse day : response.getDays()) {
if (day.getNodes() == null) {
continue;
}
for (RouteRecommendAiResponse.NodeResponse node : day.getNodes()) {
LocalDate inputVisitDate = inputVisitDateByNodeId.get(node.getNodeId());
if (!Objects.equals(inputVisitDate, node.getVisitDate())) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
}
}
}
즉, 서버가 보낸 각 node의 visitDate와 AI가 응답한 visitDate가 동일한지 확인한다.
visitOrder 검증
AI가 추천한 방문 순서는 각 날짜마다 1부터 연속되어야 한다.
정상적인 visitOrder는 다음과 같다.
Day1
A: 1
B: 2
C: 3
반면 다음과 같은 응답은 잘못된 응답이다.
중복:
A: 1
B: 1
C: 2
누락:
A: 1
B: 3
C: 4
이 검증은 validateVisitOrders()에서 처리한다.
private void validateVisitOrders(RouteRecommendAiResponse response) {
for (RouteRecommendAiResponse.DayResponse day : response.getDays()) {
List<RouteRecommendAiResponse.NodeResponse> nodes =
day.getNodes() != null ? day.getNodes() : List.of();
List<Integer> orders = nodes.stream()
.map(RouteRecommendAiResponse.NodeResponse::getVisitOrder)
.sorted()
.toList();
for (int i = 0; i < orders.size(); i++) {
if (!Objects.equals(orders.get(i), i + 1)) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
}
}
}
start/end 조건 검증
이번 리팩토링에서 특히 중요했던 부분은 start/end 조건 검증이다.
기존에는 사용자가 직접 지정한 fixed start/end만 검증하면 됐다.
하지만 리팩토링 후에는 서버가 날짜 그룹에서 계산한 start/end도 AI 요청에 포함된다.
따라서 검증 기준은 사용자가 보낸 요청값이 아니라, 서버가 AI에게 보낸 요청값이어야 한다.
private void validateDayConditions(
RouteRecommendAiRequest request,
RouteRecommendAiResponse response
) {
Map<LocalDate, RouteRecommendAiRequest.DayRequest> requestDayByDate =
request.getDays().stream()
.collect(Collectors.toMap(
RouteRecommendAiRequest.DayRequest::getDate,
Function.identity(),
(existing, replacement) -> replacement
));
Map<LocalDate, List<RouteRecommendAiResponse.NodeResponse>> responseNodesByDate =
response.getDays().stream()
.collect(Collectors.toMap(
RouteRecommendAiResponse.DayResponse::getDate,
day -> day.getNodes() != null
? day.getNodes().stream()
.sorted(Comparator.comparing(RouteRecommendAiResponse.NodeResponse::getVisitOrder))
.toList()
: List.of(),
(existing, replacement) -> replacement
));
for (RouteRecommendAiRequest.DayRequest requestDay : requestDayByDate.values()) {
LocalDate date = requestDay.getDate();
List<RouteRecommendAiResponse.NodeResponse> dayNodes =
responseNodesByDate.getOrDefault(date, List.of());
Long startNodeId = requestDay.getStartNodeId();
Long endNodeId = requestDay.getEndNodeId();
if (startNodeId != null) {
if (dayNodes.isEmpty()
|| !startNodeId.equals(dayNodes.get(0).getNodeId())) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
}
if (endNodeId != null) {
if (dayNodes.isEmpty()
|| !endNodeId.equals(dayNodes.get(dayNodes.size() - 1).getNodeId())) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
}
}
}
예를 들어 서버가 다음 조건을 AI에게 보냈다고 하자.
Day1 startNodeId = A
Day1 endNodeId = C
그러면 AI 응답은 반드시 다음 형태여야 한다.
Day1: A -> ... -> C
다음과 같은 응답은 잘못된 응답이다.
B -> A -> C
A가 첫 번째가 아니기 때문이다.
A -> C -> B
C가 마지막이 아니기 때문이다.
이렇게 검증해야 AI가 서버가 확정한 시작/종료 조건을 무시하는 것을 막을 수 있다.
서버 기준으로 Edge 재구성하기
AI 응답 검증의 마지막 단계는 edge 재구성이다.
AI에게 edge 정보를 전달하긴 하지만, AI가 응답한 edge를 그대로 신뢰하지 않는다.
서버는 AI가 추천한 노드 순서를 기준으로 인접한 노드 사이의 edge를 다시 찾는다.
예를 들어 AI가 다음과 같은 순서를 추천했다고 하자.
Day1: A -> C -> B
그러면 서버는 필요한 edge를 다시 계산한다.
필요한 edge:
A -> C
C -> B
이 과정은 rebuildEdgesInResponse()에서 처리한다.
private void rebuildEdgesInResponse(
RouteRecommendAiRequest request,
RouteRecommendAiResponse response
) {
Map<String, RouteRecommendAiRequest.EdgeRequest> inputEdgeMap = request.getDays().stream()
.flatMap(day -> day.getEdges().stream())
.collect(Collectors.toMap(
edge -> edgeKey(edge.getFromNodeId(), edge.getToNodeId()),
Function.identity(),
(existing, replacement) -> existing
));
for (RouteRecommendAiResponse.DayResponse day : response.getDays()) {
List<RouteRecommendAiResponse.NodeResponse> sortedNodes =
day.getNodes() != null
? day.getNodes().stream()
.sorted(Comparator.comparing(RouteRecommendAiResponse.NodeResponse::getVisitOrder))
.toList()
: List.of();
List<RouteRecommendAiResponse.EdgeResponse> rebuiltEdges = new ArrayList<>();
int totalMinutes = 0;
int totalDistance = 0;
for (int i = 0; i < sortedNodes.size() - 1; i++) {
Long fromNodeId = sortedNodes.get(i).getNodeId();
Long toNodeId = sortedNodes.get(i + 1).getNodeId();
RouteRecommendAiRequest.EdgeRequest inputEdge =
inputEdgeMap.get(edgeKey(fromNodeId, toNodeId));
if (inputEdge == null) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
RouteRecommendAiResponse.EdgeResponse edgeResponse =
new RouteRecommendAiResponse.EdgeResponse();
edgeResponse.setEdgeId(inputEdge.getEdgeId());
edgeResponse.setFromNodeId(inputEdge.getFromNodeId());
edgeResponse.setToNodeId(inputEdge.getToNodeId());
edgeResponse.setEstimatedTimeMinutes(inputEdge.getEstimatedTimeMinutes());
edgeResponse.setDistanceMeters(inputEdge.getDistanceMeters());
rebuiltEdges.add(edgeResponse);
totalMinutes += inputEdge.getEstimatedTimeMinutes();
totalDistance += inputEdge.getDistanceMeters();
}
day.setEdges(rebuiltEdges);
if (day.getSummary() == null) {
day.setSummary(new RouteRecommendAiResponse.Summary());
}
day.getSummary().setTotalEstimatedTimeMinutes(totalMinutes);
day.getSummary().setTotalDistanceMeters(totalDistance);
}
}
먼저 서버가 AI 요청에 넣었던 edge들을 Map으로 만든다.
Map<String, RouteRecommendAiRequest.EdgeRequest> inputEdgeMap = request.getDays().stream()
.flatMap(day -> day.getEdges().stream())
.collect(Collectors.toMap(
edge -> edgeKey(edge.getFromNodeId(), edge.getToNodeId()),
Function.identity(),
(existing, replacement) -> existing
));
key는 fromNodeId-toNodeId 형태로 만든다.
private String edgeKey(Long fromNodeId, Long toNodeId) {
return fromNodeId + "-" + toNodeId;
}
그다음 AI가 추천한 순서를 visitOrder 기준으로 정렬하고, 인접한 두 노드 사이의 edge를 찾는다.
AI 추천 순서:
A -> C -> B
확인할 edge:
A-C
C-B
만약 해당 edge가 없다면 AI 추천 결과는 사용할 수 없다.
if (inputEdge == null) {
throw new BusinessException(ErrorCode.INVALID_AI_RECOMMENDATION);
}
이렇게 처리한 이유는 이동 시간과 거리 데이터의 신뢰 주체가 AI가 아니라 서버이기 때문이다.
AI는 순서를 추천할 수 있다.
하지만 실제 이동 시간과 거리 값은 서버가 생성하고 관리하는 edge 데이터를 기준으로 해야 한다.
회고
느낀점
이번 AI 일정 자동 배치 기능을 구현하면서 가장 크게 느낀 점은, AI를 활용한다고 해서 모든 판단을 AI에게 맡기는 것이 좋은 설계는 아니라는 점이었다.
처음에는 “AI가 관광지를 날짜별로 배치하고, 방문 순서까지 모두 정해주면 되지 않을까?”라고 생각했다. 하지만 실제로 구현을 하다 보니 고려해야 할 조건이 많았다. 사용자가 직접 지정한 시작/종료 관광지가 있을 수 있고, AI가 없는 nodeId를 만들어내거나, 기존 node를 누락하거나, 날짜를 임의로 바꿀 가능성도 있었다.
특히 일정 데이터는 DB에 직접 반영되는 값이기 때문에, AI의 응답을 그대로 믿고 저장하는 것은 위험하다고 판단했다. 그래서 AI가 판단할 수 있는 범위를 제한하고, 서버가 책임져야 할 부분을 명확히 나누었다.
최종적으로 서버는 날짜별 관광지 그룹을 만들고, 각 날짜의 start/end 조건을 정하며, AI 응답을 검증하는 역할을 맡았다. 반면 AI는 서버가 확정한 날짜 그룹 안에서 이동 시간과 관광지 유형을 고려해 visitOrder를 추천하는 역할만 맡도록 했다.
이 구조를 만들면서 AI 기능에서 중요한 것은 단순히 AI API를 호출하는 것이 아니라, AI가 판단해도 되는 영역과 판단하면 안 되는 영역을 구분하는 것이라는 점을 배웠다.
AI는 여러 조건을 함께 고려해 추천을 만드는 데 강점이 있다. 하지만 서비스의 데이터 정합성, 사용자 고정 조건, 날짜 범위, edge 신뢰성까지 AI에게 맡기면 결과를 예측하기 어려워진다. 따라서 AI를 사용할 때는 “AI가 틀렸을 때 서버가 어떻게 막을 것인가”까지 함께 설계해야 한다는 것을 느꼈다.
개선점
물론 현재 구조에도 개선할 점은 있다.
1. seed 선택 방식
현재는 첫 seed를 id 기준으로 안정적으로 선택하고, 이후 seed는 기존 seed들과 멀리 떨어진 노드를 선택한다. 이 방식은 결과가 매번 달라지지 않는다는 장점이 있지만, 1일차와 3일차에 이미 fixed 노드가 있고 2일차만 비어 있는 경우에는 단순히 id가 작은 노드가 seed로 들어갈 수 있다. 이런 경우에는 앞뒤 날짜 그룹과의 연결성을 고려해, 중간 날짜에 더 자연스러운 seed를 선택하는 방식으로 개선할 수 있을 것 같다.
2. 날짜 그룹 배치 로직
현재 날짜 그룹 배치는 거리 기반 휴리스틱에 가깝다. 즉, 모든 경우의 수를 탐색해서 전역 최적해를 찾는 방식은 아니다. 관광지 수가 많아질수록 완전탐색은 현실적으로 어렵기 때문에 현재는 가까운 날짜 그룹에 노드를 붙이는 방식을 사용했지만, 이후에는 클러스터링 알고리즘을 더 정교하게 적용하거나, 숙소 위치와 실제 이동 시간까지 함께 고려해 날짜 그룹의 품질을 높일 수 있을 것 같다.
3. 동선을 정하는 규칙
세 번째로, 현재는 날짜 안의 동선 최적화에서 이동 시간과 관광지 유형을 중심으로 판단한다. 하지만 실제 여행에서는 영업시간, 식사 시간, 숙소 체크인/체크아웃 시간, 사용자의 이동 편의성 같은 조건도 중요하다. 특히 우리 서비스가 이동 약자를 위한 여행 플래너라는 점을 생각하면, 단순히 이동 시간이 짧은 경로보다 휠체어 이동 가능성, 경사로 여부, 엘리베이터 유무 같은 접근성 정보를 함께 고려하는 방향으로 확장할 필요가 있다.
'개인 프로젝트 > modu' 카테고리의 다른 글
| [daon] 11일차 개발 회고록 (0) | 2026.06.28 |
|---|---|
| [daon] 9일차 개발 회고록 (0) | 2026.06.14 |
| [daon] 8일차 개발 회고록 (0) | 2026.06.10 |
| [daon] 7일차 개발 회고록 (0) | 2026.06.07 |
| [daon] 6일차 개발 회고록 (0) | 2026.05.29 |