시내버스 남은 시간 조회 API

송선권·2024년 3월 16일
2
post-thumbnail

배경

버스 조회 API를 작성하던 중 시내버스 Open API 정보를 불러오는 로직이 필요하여 정리하게 되었다.

요구사항

  • 출발지와 도착지, 그리고 버스 타입을 입력받는다.
    - 시내버스를 제외한 타입은 이번 게시글과 관련없으므로 생략하겠다.
  • 입력된 파라미터에 해당하는 버스가 언제 정류장에 도착하는지 반환한다.
    - 다음 버스와 그 다음 버스까지 반환한다.
    - 버스 정보가 존재하지 않는다면(버스 도착 정보가 아직 없다면) 도착 정보로 null을 반환한다.
    - 초(sec) 단위로 반환한다.

공공데이터포털

공공데이터포털(Open API)에서는 누구나 사용할 수 있도록 다양한 API를 제공한다. 그 중 "국토교통부(TAGO) 버스도착정보"를 사용하기로 했다. 호출 과정에서 버스 정류장 노드 ID가 필요한데, 이 ID는 "국토교통부 전국 버스정류장 위치정보"를 참고했다.

외부 API 호출

외부 API 호출 로직 자체는 아주 손쉽게 구현 가능했다. 공공데이터포털에서 API를 호출하는 샘플 코드를 제공하기 때문이다. 덕분에 샘플 코드를 활용하고 입력 파라미터만 커스텀하여 호출 로직을 작성할 수 있었다.

private String getRequestURL(String cityCode, String nodeId) throws UnsupportedEncodingException {  
    String url = "http://apis.data.go.kr/1613000/BusSttnInfoInqireService/getCrdntPrxmtSttnList";  
    String contentCount = "30";  
    StringBuilder urlBuilder = new StringBuilder(url);  
    urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(OPEN_API_KEY, ENCODE_TYPE));  
    urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + encode(contentCount, ENCODE_TYPE));  
    urlBuilder.append("&" + encode("cityCode", ENCODE_TYPE) + "=" + encode(cityCode, ENCODE_TYPE));  
    urlBuilder.append("&" + encode("nodeId", ENCODE_TYPE) + "=" + encode(nodeId, ENCODE_TYPE));  
    return urlBuilder.toString();  
}

파싱

Open API 응답은 Json 형태로 들어온다. 자바에서 json을 객체에 쉽게 매핑하기 위해 gson(google json) 라이브러리를 사용했다.

기존 로직

이번 API를 작성하면서 가장 고민되었던 부분은 버스 정류장 노드 ID를 반환하는 로직이다. 동아리 서비스의 옛날 코드를 보면 정류장 TO 정류장 네이밍으로 enum을 선언하여 API에서 대응하는 모든 정류장을 일일이 매핑하고 있었다. 현재는 대응하는 정류장 수가 매우 적기에 나름 직관적이고 간결했다.

하지만 이번 마이그레이션에서는 이 로직을 그대로 가져가고 싶지 않았다. 출발지와 도착지 정류장을 일일이 enum에 매핑한다면 enum 값들은 그 수가 n제곱으로 늘어날 것이다. 즉, API에서 제공하는 정류장 수가 늘어날수록 유지보수가 점점 힘들어질 것이다. 따라서 유지보수에 용이한 코드를 작성하기 위해 로직을 바꾸기로 했다.

새로운 로직

같은 정류장에서도 방향이 다를 수 있다는 점이 골치아팠다. 셔틀버스 조회 로직에서는 출발지를 기준으로 to와 from 개념으로 방향을 분류했는데, 그 로직을 여기에 적용하자니 기차역에서 출발(from)하더라도 도착지에 따라 (방향이 달라지기에) 노드 ID가 다를 수 있다는 문제가 있었다.

결국 to-from을 버리고 "방향"이라는 개념에 착안하여 로직을 새로 구상해보기로 했다. 실제 버스 노선은 상행선하행선으로 나뉜다. 상행선은 기점에서 종점으로, 하행선은 종점에서 기점으로 운행된다. 그리고 버스 정류장은 상행 하행 여부에 따라 2개의 노드로 나뉜다.

기점: 출발지(차고지), 종점: 도착지
버스마다 다르지만 우리 학교에 오는 버스들은 기점 -> 종점(회차) -> 기점 방향으로 운행한다.
우리 학교는 시내에서 멀리 떨어져있기에 차고지가 없는 종점이라고 생각하면 이해하기 쉽다. 즉 상행선을 등교선, 하행선을 하교선으로 생각할 수 있다.

이를 통해 작성한 주요 로직은 다음과 같다.

  1. 버스 정류장을 상행과 하행으로 분류한다.(BusDirection.java)
  2. 버스 정류장마다 상행과 하행의 노드 ID를 기입한다.(BusStationNode.java)
  3. 상행과 하행 중 하나만 선택하여 버스 정류장을 일관적인 순서로 정의한다.(BusStation.java)
  4. 버스 정류장 순서를 기반으로 상행과 하행을 분류하여(BusStation.getDirection()) 적절한 노드 ID(BusStation.getNodeId())를 반환한다.
public enum BusDirection {  
    NORTH, SOUTH;
}
public enum BusStationNode {  
    TERMINAL(Map.of(NORTH, "CAB285000686", SOUTH, "CAB285000685")), // 종합터미널  
    KOREATECH(Map.of(NORTH, "CAB285000406", SOUTH, "CAB285000405")), // 코리아텍  
    STATION(Map.of(NORTH, "CAB285000655", SOUTH, "CAB285000656")), // 천안역 동부광장  
    ;  
  
    private final Map<BusDirection, String> node;  
  
    BusStationNode(Map<BusDirection, String> node) {  
        this.node = node;  
    }  
}
public enum BusStation {  
    KOREATECH("koreatech", List.of("학교", "한기대"), BusStationNode.KOREATECH),  
    STATION("station", List.of("천안역", "천안역(학화호두과자)"), BusStationNode.STATION),  
    TERMINAL("terminal", List.of("터미널", "터미널(신세계 앞 횡단보도)"), BusStationNode.TERMINAL),  
    ;  
  
    private final String name;  
    private final List<String> displayNames;  
    private final BusStationNode node;  
  
    public static BusDirection getDirection(BusStation depart, BusStation arrival) {  
        if (depart.ordinal() < arrival.ordinal()) {  
            return BusDirection.SOUTH;  
        }  
        return BusDirection.NORTH;  
    }
    
    public String getNodeId(BusDirection direction) {  
	    return node.getId(direction);  
	}
}

캐싱

Open API는 달에 10,000회만 호출이 가능하기 때문에 캐싱 기능을 사용하기로 했다.

버스가 운행하는 시간대에 1분 단위로 캐싱을 해도 달에 20,000회가 넘는 호출이 일어난다. 따라서 현재 서비스 규모가 크지 않은 만큼 요청이 들어올 때마다 외부 API를 호출하기로 했다.

레디스 캐싱

말은 그렇게 했지만 실제로 매번 외부 API를 호출하는 것은 문제가 발생할 여지가 많다. 따라서 레디스로 캐싱을 수행하기로 했다. 남은 시간 조회 시 해당 정류장 데이터가 캐싱되어 있다면 현재 시각과 캐시 저장 시각을 비교하여 남은 시간을 재계산 후 반환한다. 수식으로 나타내면 다음과 같다.

남은 시간 = 저장된 남은 시간 - (현재 시각 - 캐시 저장 시각)

캐시 유효성 검증

캐싱된 데이터에 대해 유효성 검증을 어떻게 해야 하는지 고민이 많이 되었다. 캐싱이란 기본적으로 조회된 내용에 대해 캐싱해두고, 캐싱된 데이터가 있다면 그 내용을 반환하는 것이다. 하지만 Open API를 호출한 경우, 조회 결과에 버스 도착 정보가 없으면 캐싱할 데이터도 없을 것이다. 요청이 들어온 시점에 캐싱된 데이터가 없다면 버스 도착 정보가 없는 것인지, 아직 캐싱이 안된 것인지 판단하기 힘들다.

그래서 처음에는 Pair를 사용해보았다. Pair<String, ...> 형태를 사용해서 응답이 비어있더라도 버스 정류장 ID를 유지시킬 수 있는 형태를 만들었다. 이후 캐싱할 때 해당 ID를 함께 저장하여 버스 도착 정보가 없다는 데이터를 캐싱시킬 수 있었다. 하지만 코드가 너무 못생겨져서 고민 끝에 새로운 방법을 구상해냈다.

우리 서비스에서는 특정 API에서 버전 정보를 제공한다. 이를 위해 시내버스 버전 정보도 저장을 해줘야 하는데, 이 점에 집중했다.

버전 정보가 업데이트되었다는 것은 다시말해 그 시점이 최신 정보가 저장된 마지막 시점이라는 것이다. 그리고 버전 정보에는 updated_at 필드가 존재한다. 이 정보를 활용하면 캐시의 유효성 검증이 가능해진다.

캐시 보관 기간을 1분이라고 가정해보자. 그리고 Open API를 통해 버스 도착 정보를 조회했는데 조회된 버스 정보가 없었다. 그럼 캐시에는 아무 것도 저장되지 않는다. 이후 다시 동일한 정류장에 대해 버스 도착 정보 조회 요청이 들어오면 캐싱된 정보가 있는지 확인해야 한다. 여기서 우리는 내부적으로 기록해두었던 시내버스 버전 정보의 최신화 일자(updated_at)를 확인한다. 만약 이 시각이 현재 시각으로부터 1분 이상 차이가 나지 않는다면 버스 도착 정보가 없다는 응답을 보낼 수 있다. 만약 1분 이상 차이가 난다면 Open API를 재조회하여 최신 정보를 반환할 수 있다.(이 때 조회된 정보를 다시 캐싱한다.)

회고

버스 노선이나 각종 용어(상행, 하행, 기점, 종점 등)에 대해 하나도 몰랐는데, 이번 API를 작성하면서 의도치 않게 버스에 대해 많은 내용을 배울 수 있었다. 또한 Open API를 호출하는 방법도 알아볼 수 있었고, 그 덕분에 캐싱 방법에 대해 다양한 고민을 해볼 수 있어서 좋았다.

0개의 댓글