문제 정의
현재 WHEERE 프로젝트 예약하기 화면에는 출발지와 도착지를 입력하는 부분이 존재한다.
- 사용자가 입력한 출발지 및 도착지는 Google geocoding API를 사용해 좌표로 변환된다.
- 이렇게 변환된 좌표는 ODSay LAB에서 제공해주는 대중교통 길찾기 API를 통해 해당 경로에 대한 버스 경로를 제공하는데 사용된다.
- 서버는 ODSay에서 제공해주는 응답 결과에서 클라이언트가 필요한 적절한 값만을 추출한다.
- 그리고 추출한 버스가 지나가는 정류장에 대한 시간을 MySQL 데이터베이스에 저장되어 있는 버스 정보와 비교, 조회해서 사용자에게 결과를 반환해주는 것을 목표로 한다.
ODSay 대중교통길찾기 API 응답 결과 중 필요한 값
- outTrafficCheck
- 도시간 "직통" 탐색 결과 유무 (환승 X)
- busTransitCount
- 버스 환승 카운트
- payment
- 총 요금
- firstStartStation
- 최초 출발역/정류장
- lastEndStation
- 최종 도착역/정류장
- trafficType
- 이동 수단 종류 (도보, 버스, 지하철)
- 1-지하철, 2-버스, 3-도보
- sectionTime
- 이동 소요 시간
- busNo
- 버스 번호 (버스인 경우에만 필수)
- stationID
- 정류장 ID
- stationName
- 정류장 명칭
제한 사항
- 본 프로젝트에서 교통약자 특성상 최대 환승 버스 개수는 2개로 제한한다.
- 도시간 이동 / 도시내 이동 중 도시내 이동을 ODSay로부터 제공받는다.
- 도시 내 경로수단은 지하철을 제외한 버스로 통일한다.
- 버스 종류는 저상버스로 제한한다.
- 각 저상버스의 휠체어 좌석은 최대 2개로 제한한다.
구조
데이터베이스 구조
현재 프로젝트의 데이터베이스 구조는 다음과 같다.
- member: 사용자의 정보
- reservation: 예약 정보
- transfer: 환승을 포함한 예약한 버스의 정보
- bus: 버스의 정보
- driver: 버스 기사 정보
- station: 정류장 정보
- platform: 각 버스가 특정 정류장을 지나가는 정보
- seat: 각 버스가 특정 정류장을 지나갈 때 남은 휠체어 좌석 수
요청 과정
- 사용자가 '출발지', '도착지' 지역명 그리고 예약하려는 날짜를 입력한다.
- 클라이언트에서 '출발지', '도착지' 지역의 좌표를 변환해 서버에 경로 조회를 요청한다.
- 서버에서 해당 좌표 정보를 요청 파라미터로 사용해 ODSay로부터 경로 정보를 조회한다.
- 서버에서는 데이터베이스에 미리 저장된 버스 목록들과 각 버스가 지나가는 정류장 정보 그리고 각 정류장을 지나는 시간 정보를 활용해 사용자에게 결과를 반환한다.
- 결과는 사용자가 입력한 '출발지'와 '도착지' 경로를 지나는 버스 정보(환승 포함)와 시간 정보를 제공해준다.
어려웠던 점 & 해결 방법
- ODSay에서 제공해주는 버스 경로 정보를 데이터베이스에 저장되어 있는 버스 정보와 연동해서 각 정류장별로 도착 예상 시간을 측정하고 남아 있는 좌석을 계산하고 환승까지 고려하니 로직이 너무 복잡했다.
- 코딩 실력이 부족해 4중 for문이 생성되었으며 코드가 깔끔하지 못했다.
-> 3중 for문으로 코드를 개선하였다.
- 코딩 실력이 부족해 4중 for문이 생성되었으며 코드가 깔끔하지 못했다.
- 경로 조회시 시간이 최소 30초 이상이 소요되었다.
- 프로젝트 기획 초기에는 사용자가 경로를 요청하면 해당 경로에 해당하는 버스 정보를 모두 제공해주었다.
- 추후 시간대별로 버스 경로 정보를 제공해주는 것으로 변경하였다.
- 버스가 지나가는 모든 정류장을 각각 조회하던 방식에서 In절을 사용해 한번에 조회하는 방식으로 변경하였다.
-> 30초 이상 소요되던 기능이 3초 이하로 줄어들었다.
- 프로젝트 기획 초기에는 사용자가 경로를 요청하면 해당 경로에 해당하는 버스 정보를 모두 제공해주었다.
ODSay -> 서버 구현
요청DTO 구조 (클라이언트 -> 서버)
RetrieveRoutesRequest
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RetrieveRoutesRequest {
private String sx; // 출발지 경도
private String sy; // 출발지 위도
private String ex; // 도착지 경도
private String ey; // 도착지 위도
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate rDate; // 예약 날짜
}
ODSay -> 서버
AllCourseCase
@Data
public class AllCourseCase {
private int outTrafficCheck; // 직통 유무
private List<Course> courses; // 가능한 모든 버스 경로
}
Course
@Data
public class Course {
private int busTransitCount; // 버스 환승 횟수
private int payment; // 요금
private String firstStartStation; // 최초 정류장
private String lastEndStation; // 최종 정류장
private List<SubCourse> subCourses; // 부분 경로
}
SubCourse
@Data
public class SubCourse {
private int trafficType; // 이동 수단 종류(1-지하철, 2-버스, 3-도보)
private int sectionTime; // 이동 소요 시간
private Optional<BusLane> busLane; // 상세 버스 정보
}
- busLane
- trafficType 값이 2인 경우에만 존재한다.
BusLane
@Data
@AllArgsConstructor
public class BusLane {
private List<String> busNoList; // 버스 번호 리스트
private int boardStationID; // 승차 정류장 ID
private String boardStationName; // 승차 정류장 명칭
private int alightStationID; // 하차 정류장 ID
private String alightStationName; // 하차 정류장 명칭
public static BusLane createBusLane(List<String> busNoList, int boardStationID, String boardStationName, int alightStationID, String alightStationName) {
return new BusLane(busNoList, boardStationID, boardStationName, alightStationID, alightStationName);
}
}
ODSay -> 서버 로직
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class MemberService {
@Value("${odsay-key}")
private String apiKey;
...
public Optional<AllCourseCase> checkRoutes(RetrieveRoutesRequest requestDto) {
// 대주교통 길차지 API Url 설정
StringBuilder urlBuilder = setUrl(requestDto);
// GET 방식으로 전송해서 파라미터 받아오기
try {
URL url = createUrl(urlBuilder);
String jsonResult = extractJson(url);
// json 파싱
return extractAllCourseCase(jsonResult);
} catch (MalformedURLException e) {
e.printStackTrace();
String message = e.getMessage();
log.error("message = {}", message);
return Optional.empty();
}
}
...
}
- setUrl(requestDto)
- ODSay 대중교통 길찾기 API를 사용하기 위해 적절한 URL을 세팅한다.
- createUrl(urlBuilder)
- 세팅한 URL을 사용해 ODSay 대중교통 길찾기 API에 요청할 URL을 생성한다.
- extractJson(url)
- ODSay 대중교통 길찾기 API를 실제 요청하고 필요한 데이터를 추출한다.
- extractAllCourseCase(jsonResult)
- 추출한 데이터를 클라이언트에 응답할 DTO 객체에 파싱한다.
데이터베이스로부터 버스 경로 정보 추출 로직
/**
* 대중교통 길찾기 API로 추출한 버스 시간표 조회
*/
public RetrieveRoutesResult checkBusTime(AllCourseCase allRouteCase, LocalDate rDate) {
// 모든 경우의 수
List<Course> courses = allRouteCase.getCourses();
RetrieveRoutesResult retrieveRoutesResult = new RetrieveRoutesResult();
retrieveRoutesResult.setOutTrafficCheck(allRouteCase.getOutTrafficCheck());
List<Route> routes = new ArrayList<>();
// 경우의 수 중 하나
for (Course course : courses) {
...
List<SubCourse> subCourses = course.getSubCourses();
for (SubCourse subCourse : subCourses) {
...
BusLane findBusLane = busLane.get();
...
}
Map<String, SubRoute> busMap = new HashMap<>();
for (int i = 0; i < routeCase.size(); i++) {
...
addToBusMap(rDate, busNoList, busMap, subRouteForBus, findBusRoute, stationIdList);
}
addToRoutes(routes, course, onlyWalk, routeCase, busMap);
}
retrieveRoutesResult.setRoutes(routes);
return retrieveRoutesResult;
}
private void addToRoutes(List<Route> routes, Course course, List<SubRoute> onlyWalk, MultiValueMap<Integer, String> routeCase, Map<String, SubRoute> busMap) {
List<String> firstBusNoList = routeCase.get(0);
if (routeCase.size() == 2) {
List<String> secondBusNoList = routeCase.get(1);
for (String firstBusNo : firstBusNoList) {
for (String secondBusNo : secondBusNoList) {
Route route = new Route();
...
}
}
} else if(routeCase.size() == 1){
for (String firstBusNo : firstBusNoList) {
Route route = new Route();
...
}
}
}
private void addToBusMap(LocalDate rDate, List<String> busNoList, Map<String, SubRoute> busMap, SubRoute busSubRoute, BusRoute findBusRoute, List<Long> stationIdList) {
int leftSeatsNum;
for (String busNo : busNoList) {
SubRoute subRoute = new SubRoute();
subRoute.setTrafficType(busSubRoute.getTrafficType());
subRoute.setSectionTime(busSubRoute.getSectionTime());
// 특정 버스 번호에 대해 BusId를 모두 조회하기
List<Long> findBusIds = busRepository.findBusIdByBusNoAndBusDate(busNo, rDate);
...
Map<Long, List<Platform>> busPlatformMap = new HashMap<>();
for (Long findBusId : findBusIds) {
// 출발, 도착 정류장 모두를 지나면 runBusNoList에 추가
List<Platform> findPlatforms = platformRepository.findPlatformByBusIdAndStationId(findBusId, stationIdList);
...
}
Map<Long, List<LocalTime>> combineTimes = combineLists(runBusIdList, startStationArrivalTimes, endStationArrivalTimes);
// 출발 시간을 기준으로 combineTimes 정렬
Map<Long, List<LocalTime>> timePerBus = combineTimes.entrySet().stream()
.sorted(Comparator.comparing(entry -> entry.getValue().get(0)))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> b, LinkedHashMap::new));
startStationArrivalTimes.clear();
endStationArrivalTimes.clear();
timePerBus.forEach((key, value) -> {
startStationArrivalTimes.add(value.get(0));
endStationArrivalTimes.add(value.get(1));
});
List<Long> orderedBusIds = new ArrayList<>(timePerBus.keySet());
for (Long busId : orderedBusIds) {
...
}
...
subRoute.setBusRoute(busRoute);
busMap.put(busNo, subRoute);
}
}
public static Map<Long, List<LocalTime>> combineLists(List<Long> idList, List<LocalTime> startStationArrivalTimes, List<LocalTime> endStationArrivalTimes) {
if (idList.size() != startStationArrivalTimes.size() || idList.size() != endStationArrivalTimes.size()) {
throw new IllegalArgumentException("Lists must have the same size");
}
return IntStream.range(0, idList.size())
.boxed()
.map(index -> new AbstractMap.SimpleEntry<>(idList.get(index), List.of(startStationArrivalTimes.get(index), endStationArrivalTimes.get(index))))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
- checkBusTime: 버스 시간표를 조회한다.
- 주어진 AllCourseCase와 LocalDate를 기반으로 버스 시간표를 조회하고 결과를 반환한다.
- addToRoutes: 경로와 관련된 정보를 routes 리스트에 추가한다.
- Course, onlyWalk, routeCase, busMap 등을 활용하여 Route 객체를 생성하고 routes 리스트에 추가한다.
- addToBusMap: 버스 시간표 정보를 busMap에 추가하는 메서드이다.
- rDate, busNoList, busMap, busSubRoute, findBusRoute, stationIdList 등을 활용하여 SubRoute와 BusRoute 객체를 생성하고 busMap에 추가한다.
- combineLists: 세 개의 리스트를 하나의 맵으로 합치는 메서드이다.
- idList, startStationArrivalTimes, endStationArrivalTimes를 받아서 각각의 값들을 조합하여 맵으로 반환한다.
서버 -> 클라이언트 구현
응답DTO 구조
{
"outTrafficCheck": 0,
"routes": [
{
"payment": 1250,
"busTransitCount": 1,
"firstStartStation": "덕원고등학교건너",
"lastEndStation": "대덕마을건너",
"subRoutes": [
{
"trafficType": 3,
"sectionTime": 3,
"busRoute": null
},
{
"trafficType": 2,
"sectionTime": 15,
"busRoute": {
"busId": [
107,
113,
...
],
"leftSeats": [
2,
1,
...
],
"bNo": "403",
"sStationId": 433193,
"sStationName": "덕원고등학교건너",
"sTime": [
"06:06:00",
"06:19:00",
...
],
"eStationId": 433196,
"eStationName": "대덕마을건너",
"eTime": [
"06:15:00",
"06:28:00",
...
]
}
},
{
"trafficType": 3,
"sectionTime": 9,
"busRoute": null
}
]
},
{
"payment": 1250,
"busTransitCount": 2,
"firstStartStation": "덕원고등학교건너",
"lastEndStation": "대구미술관건너",
"subRoutes": [
{
"trafficType": 3,
"sectionTime": 3,
"busRoute": null
},
{
"trafficType": 2,
"sectionTime": 13,
"busRoute": {
"busId": [
192,
197,
...
],
"leftSeats": [
2,
2,
...
],
"bNo": "939",
"sStationId": 433193,
"sStationName": "덕원고등학교건너",
"sTime": [
"06:08:00",
"07:48:00",
...
],
"eStationId": 410284,
"eStationName": "연호지건너",
"eTime": [
"06:18:00",
"07:58:00",
...
]
}
},
{
"trafficType": 3,
"sectionTime": 1,
"busRoute": null
},
{
"trafficType": 2,
"sectionTime": 10,
"busRoute": {
"busId": [
235,
244,
...
],
"leftSeats": [
2,
2,
...
],
"bNo": "수성3",
"sStationId": 434399,
"sStationName": "연호지",
"sTime": [
"05:48:00",
"06:14:00",
...
],
"eStationId": 423547,
"eStationName": "대구미술관건너",
"eTime": [
"05:54:00",
"06:21:00",
...
]
}
},
{
"trafficType": 3,
"sectionTime": 5,
"busRoute": null
}
]
},
{
"payment": 1250,
"busTransitCount": 2,
"firstStartStation": "덕원고등학교건너",
"lastEndStation": "대구미술관건너",
"subRoutes": [
{
"trafficType": 3,
"sectionTime": 3,
"busRoute": null
},
{
"trafficType": 2,
"sectionTime": 13,
"busRoute": {
"busId": [
154,
159,
...
],
"leftSeats": [
2,
2,
...
],
"bNo": "609",
"sStationId": 433193,
"sStationName": "덕원고등학교건너",
"sTime": [
"06:41:00",
"07:46:00",
...
],
"eStationId": 410284,
"eStationName": "연호지건너",
"eTime": [
"06:47:00",
"07:52:00",
...
]
}
},
{
"trafficType": 3,
"sectionTime": 1,
"busRoute": null
},
{
"trafficType": 2,
"sectionTime": 10,
"busRoute": {
"busId": [
235,
244,
...
],
"leftSeats": [
2,
2,
...
],
"bNo": "수성3",
"sStationId": 434399,
"sStationName": "연호지",
"sTime": [
"05:48:00",
"06:14:00",
...
],
"eStationId": 423547,
"eStationName": "대구미술관건너",
"eTime": [
"05:54:00",
"06:21:00",
...
]
}
},
{
"trafficType": 3,
"sectionTime": 5,
"busRoute": null
}
]
}
]
}
RetrieveRouteResult
@Data
public class RetrieveRoutesResult {
private int outTrafficCheck; // 도시간 "직통" 탐색 결과 유무 (환승 X)
private List<Route> routes;
}
Route
@Data
public class Route {
private int payment; // 총 요금
private int busTransitCount; // 버스 환승 카운트
private String firstStartStation; // 최초 출발역/정류장
private String lastEndStation; // 최종 도착역/정류장
private List<SubRoute> subRoutes;
}
SubRoute
@Data
public class SubRoute {
private int trafficType; // 이동 수단 종류 (도보, 버스, 지하철)
private int sectionTime; // 이동 소요 시간
private BusRoute busRoute;
}
BusRoute
@Data
public class BusRoute {
@JsonProperty("bNo")
private String bNo; // 버스 번호
private List<Long> busId; // 버스 ID 리스트
@JsonProperty("sStationId")
private int sStationId; // 각 버스 출발 정류장 ID
@JsonProperty("sStationName")
private String sStationName; // 각 버스 출발 정류장명
@JsonProperty("sTime")
@DateTimeFormat(pattern = "HH:mm:ss")
private List<LocalTime> sTime; // 출발 정류장 버스 도착 예정 시간 리스트
@JsonProperty("eStationId")
private int eStationId; // 각 버스 도착 정류장 ID
@JsonProperty("eStationName")
private String eStationName; // 각 버스 도착 정류장명
@JsonProperty("eTime")
@DateTimeFormat(pattern = "HH:mm:ss")
private List<LocalTime> eTime; // 도착 정류장 버스 도착 예정 시간 리스트
private List<Integer> leftSeats; // 출발 정류장에서 도착 정류장까지 남은 휠체어 좌석 수
}
컨트롤러
MemberApiController
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
@ResponseStatus(HttpStatus.OK)
@PostMapping("/request-routes")
public RetrieveRoutesResult retrieveRoutes(@RequestBody RetrieveRoutesRequest retrieveRoutesRequest) {
// 모든 경우의 수 추출
Optional<AllCourseCase> allRouteCase = memberService.checkRoutes(retrieveRoutesRequest);
// 경우의 수가 없는 경우
if (allRouteCase.isEmpty()) {
return new RetrieveRoutesResult();
}
AllCourseCase allCourseCase = allRouteCase.get();
LocalDate rDate = retrieveRoutesRequest.getRDate();
RetrieveRoutesResult retrieveRoutesResult = memberService.checkBusTime(allCourseCase, rDate);
return retrieveRoutesResult;
}
...
}
결과