티스토리 뷰

728x90

3-2. 지하철


1) 사용한 Api

 

 

열린데이터광장 메인

데이터분류,데이터검색,데이터활용

data.seoul.go.kr

서울특별시에서 제공하는 [서울시 지하철 실시간 도착정보] api 사용하였습니다.

 

 

열린데이터광장 메인

데이터분류,데이터검색,데이터활용

data.seoul.go.kr

서울 교통공사에서 제공하는 [서울시 역 코드로 지하철역별 열차 시간표 정보 검색] api를 이용하였습니다.

 

 

서울교통공사_서울 도시철도 목적지 경로정보

수도권 전체 열차 경로 탐색 데이터를 출발역, 도착역, 출발시간, 도착시간, 소요시간, 환승 횟수, 경로 역사 코드 등의 항목으로 제공하는 서비스

www.data.go.kr

서울 교통공사에서 제공하는 [서울교통공사_서울 도시철도 목적지 경로 정보] api를 이용하였습니다.

Group 64.png

하얀색 표는 서울특별시에 제공하는 실시간 도착 정보를 제공하는 api에서 사용하는 코드이고

회색 표는 서울교통공사에서 제공하는 api에서 사용하는 코드입니다.

하얀색 표의 정보는 위의 링크에서 받을 수 있고 회색 표의 경우 다음 링크에서 받을 수 있습니다.

 

 

열린데이터광장 메인

데이터분류,데이터검색,데이터활용

data.seoul.go.kr

 

두 개의 코드를 같이 사용하기 위한 고민

더보기

 

의정부역을 기준으로 두 개의 키를 조합해 보았습니다.

앞 4자리는 호선 코드를, 뒤에 6자리는 외부 코드 및 여백으로 0이 들어갔다고 생각됩니다.


강남구청역을 기준으로 두 개의 키를 조합해 보았습니다.

역시 앞 4자리는 호선 코드입니다.

뒤 6자리의 경우 강남구청의 외부 코드는 K213입니다.

K를 아스키코드로 변환하면 75가 되고 뒤의 213가 조합하면 1075075213을 만들 수 있습니다.

신설동역을 기준으로 두 개의 키를 조합해 보았습니다.

역시 앞의 4자리는 호선 코드로 동일합니다.

근데 뒤에 6자리의 경우 역 코드를 이용해서 조합이 된 것을 확인할 수 있습니다.

아스키코드로 변환해서 사용하는 것 까지는 어떻게든 할 수 있을 텐데 외부 코드를 안 쓰고 역 코드를 쓴다고 하면 또 예외 처리를 해야합니다.

특정 노선만 저렇게 처리가 된 것 같아서 불가능한 것은 아닌데 다른 예외가 있을 가능성이 있기 때문에 안심하고 사용할 수는 없었습니다.

그렇다고 역 이름을 가지고 조합을 할 수도 없습니다.

쌍용(나사렛대) -> 쌍용 서울 -> 서울역

대표적인 예시로 두 개의 테이블에서 서로 다른 이름을 사용하고 있습니다.

또한 같은 역이름이라고 해도 여러 호선이 있을 경우 또 다른 처리를 해주어야 합니다.

 

제공하는 정보만 가지고 어떻게든 해보려고 하였으나 마땅한 방법이 없어서 직접 파일을 만들어서 사용하기로 결정하였습니다.

SUBWAY_ID,SUBWAY_NAME,STATN_ID,STATN_NM,STATN_CODE,OUTER_CODE
1001,1호선,1001000100,소요산,1916,100
1001,1호선,1001000101,동두천,1915,101
1001,1호선,1001000102,보산,1914,102
1001,1호선,1001000103,동두천중앙,1913,103
1001,1호선,1001000104,지행,1912,104
1001,1호선,1001000105,덕정,1911,105
...

csv 파일로 다음과 같이 조합을 하여서 만들었습니다.

해당 파일을 불러와서 데이터베이스에 저장 후 사용하였습니다.


2) 데이터베이스

/**
 * 지하철 역 정보
 * @param stationCode 서울교통공사에서 제공하는 API에서 사용하는 코드
 * @param stationId 서울특별시에서 제공하는 API에서 사용하는 코드
 * @param outerCode 서울교통공사에서 제공하는 API에서 사용하는 외부 코드
 * @param stationName 지하철 역 이름
 * @param lineNum 호선 번호
 * @param lineName 호선 이름
 * **/
@Entity
data class StationEntity(
    @PrimaryKey
    val stationCode: String,
    val stationId: String,
    val outerCode: String,
    val stationName: String,
    val lineNum: String,
    val lineName: String,
)

위에서 만든 csv의 정보들을 담을 데이터베이스 Entity입니다.

/**
 * 즐겨 찾기
 * @param type Bus, BusStop, Subway, SubwayDestination 중 1개 선택 (const 제공)
 * @param startTime 시작 시간
 * @param endTime 종료 시간
 * @param name 이름
 * @param id 서버 조회를 위한 아이디
 * @param timeStamp 등록 시간
 * **/
@Entity(indices = [
    Index(unique = true, value = ["id", "name"])
])
data class FavoriteEntity(
    @PrimaryKey(autoGenerate = true) val index: Int,
    val type: String,
    val startTime: String,
    val endTime: String,
    val name: String,
    val id: String,
    val timeStamp: Long
) {
        companion object {
        const val TypeBus = "Bus"
        const val TypeBusStop = "BusStop"
        const val TypeSubway = "Subway"
        const val TypeSubwayDestination = "SubwayDestination"
        const val Separator = "|||"
    }
}

즐겨찾기 정보를 담는 데이터베이스 Entity입니다.


3) 지하철역 검색

3.2.1 지하철역 검색.png3.2.1-2 지하철 역 실시간 도착 정보 조회.png

이 화면에서는 지하철역 리스트 형식으로 화면에 나타납니다.

리스트의 아이템을 선택 시 지하철 도착 정보를 바텀 시트로 보여줍니다.

출발 또는 도착을 선택 시 상단 카드가 활성화되고 ‘>’을 클릭하여 목적지 경로를 조회 페이지로 이동합니다.

검색을 통해 특정 문구가 들어간 지하철역 조회가 가능하며 즐겨찾기만 표시할 수 있습니다.

override suspend fun fetchStationItems(
    stationName: String,
    isFavoriteOnly: Boolean,
) = flow {
    val allOrFavorite = if (isFavoriteOnly) listOf(1) else listOf(0, 1)
    if (subwayDao.getNumberOfStations() <= 0) {
        insertSubwayStation()
    }
    subwayDao.fetchStationItems(stationName, allOrFavorite).collect {
        emit(it)
    }
}

private suspend fun insertSubwayStation() {
    try {
        val inputStream = context.resources.openRawResource(R.raw.station_info)
        val reader = BufferedReader(InputStreamReader(inputStream))
        val stationInfoList = mutableListOf<StationEntity>()
        reader.forEachLine {
            val infoList = it.split(',')
            stationInfoList.add(StationEntity.fromCsv(infoList = infoList))
        }

        subwayDao.insertSubwayStation(stationInfoList.drop(1))
    } catch (e: IOException) {
        e.printStackTrace()
    } catch (e: NotFoundException) {
        e.printStackTrace()
    }
}

Repository에서 subwayDao.getNumberOfStations()를 통해 지하철역 정보가 데이터베이스에 있는지 확인합니다.

없다고 하면 insertSubwayStation()를 통해 csv 파일을 읽어와서 데이터베이스에 내용을 채워줍니다.

데이터가 있다고 하면 다음의 쿼리를 통해 데이터를 읽어옵니다.

@Query("SELECT stationCode, stationId, stationName, group_concat(lineNum,  ',') lineNames, CASE WHEN Favorite.id IS NULL THEN 0 ELSE 1 END AS isFavorite\n" +
        "FROM StationEntity as Station\n" +
        "LEFT JOIN FavoriteEntity as Favorite\n" +
        "ON Station.stationCode = Favorite.id\n" +
        "WHERE stationName LIKE '%' || :stationName || '%' AND isFavorite IN (:allOrFavorite)\n" +
        "GROUP BY stationName\n" +
        "ORDER BY isFavorite DESC, stationName ASC")
fun fetchStationItems(
    stationName: String = "",
    allOrFavorite: List<Int>,
): Flow<List<StationItem>>

이번 쿼리는 화면 요구사항이 있어서 좀 복잡하게 되었습니다.

  1. FavoriteEtity를 LEFT JOIN 하여 즐겨찾기 정보와 역의 정보를 id를 기준으로 연결합니다.
    CASE WHEN Favorite.id IS NULL THEN 0 ELSE 1 END AS isFavorite
    CASE를 사용하여 즐겨찾기가 있을 경우 1(true)를, 없을 경우 0(false)를 반환합니다.
  2. GROUP BY stationName를 이용하여 지하철역으로 그룹화합니다.
    ex) 도봉산의 경우 1호선과 7호선이 있는데 이를 2개의 Row가 아닌 하나의 Row로 만듭니다.
    그룹화한 뒤 각 호선 정보에 대해서는 유지를 해야 하기 때문에
    group_concat(lineNum, ',') lineNames를 하여 ‘1호선, 7호선’ 이렇게 나오도록 설정하였습니다.
  3. WHERE 조건을 통해 역 이름이나, 즐겨찾기만 출력 여부에 대해 설정할 수 있도록 하였습니다.
  4. 정렬 기준은 즐겨찾기를 내림차순으로 하고, 역이름을 오름차순으로 설정하였습니다.
val stationItems = stationItemsSharedFlow
    .flatMapLatest {
        repository.fetchStationItems(
            stationName = it,
            isFavoriteOnly = isFavoriteOnly
        )
    }
    .onStart { _isProgress.value = true }
    .onCompletion { _isProgress.value = false }
    .retryWhen { cause, attempt ->
        if (cause is NoSuchElementException && attempt < 2) {
            true
        } else {
            throw cause
        }
    }
    .catch { it.printStackTrace() }

읽어온 데이터는 위와 같이 ViewModel에서 불러와서 화면에 출력합니다.

이번에는 retryWhen이라는 것을 사용해 보았습니다.

예외사항이 발생했을 때 컨트롤할 수 있는 기능인데

cuase를 통해 예외 발생 원인을, attempt를 통해 재시도 횟수의 정보를 받을 수 있습니다.

혹시라도 일시적 오류로 인해 데이터가 나오지 않을 경우를 대비하여 1번 더 재시도를 할 수 있도록 설정해 보았습니다.

subwayLineId 지하철호선ID
updnLine 상하행선구분 (2호선 : (내선:0,외선:1),상행,하행)
prevStationId 이전지하철역ID
nextStationId 다음지하철역ID
destinationName 종착지하철역명
stationId 지하철 역 ID
subwayType 열차 종류 (급행, ITX)
arrTime 열차도착예정시간 (단위:초)

[서울시 지하철 실시간 도착정보] api에서 위의 데이터 + a를 한 묶음으로 해서 리스트로 데이터가 넘어옵니다.

/**
 * @param subwayLineId 지하철 호선 아이디
 * @param stationCode 지하철 역 코드
 * @param currentStationName 현재 역 이름
 * @param prevStationName 이전 역 이름
 * @param nextStationName 다음 역 이름
 * @param arrItemList 지하철 도착 정보
 * **/
data class SubwayArrival(
    val subwayLineId: String,
    val stationCode: String,
    val currentStationName: String,
    val prevStationName: String,
    val nextStationName: String,
    val arrItemList: List<SubwayArrivalItem>
)

/**
 * @param arrInfo 도착 정보 : 00행 0000
 * @param isUpLine 상행 또는 내선 여부
 * **/
data class SubwayArrivalItem(
    val arrInfo: String,
    val isUpLine: Boolean
)

저는 제 화면에 맞게 위와 같은 형식으로 데이터를 가공하려고 합니다.

override fun fetchRealtimeStationArrivals(
    keyword: String
) = flow {
    val items = mutableListOf<SubwayArrival>()
    val result = client.fetchRealtimeStationArrivals(keyword)

    if (result.isEmpty()) throw NoSuchElementException("데이터가 없습니다.")

    items.addAll(
        result
            .groupBy { it.subwayLineId }
            .map { (subwayLineId, subwayArrivalInfoList) ->
                createSubwayArrival(
                    stationName = result[0].stationName,
                    subwayLineId = subwayLineId,
                    subwayArrivalInfoList = subwayArrivalInfoList
                )
            }
    )
    emit(items.sortedBy { it.subwayLineId })
}

private suspend fun createSubwayArrival(
    stationName: String,
    subwayLineId: String,
    subwayArrivalInfoList: List<SubwayArrivalInfo>
): SubwayArrival {
    val stationCode = fetchSubwayCodeById(subwayArrivalInfoList[0].stationId)
    val prevStation =
        subwayDao.fetchSubwayNameById(subwayArrivalInfoList[0].nextStationId)
    val nextStation =
        subwayDao.fetchSubwayNameById(subwayArrivalInfoList[0].prevStationId)

    return SubwayArrival(
        subwayLineId = subwayLineId,
        stationCode = stationCode,
        prevStationName = prevStation,
        nextStationName = nextStation,
        currentStationName = stationName,
        arrItemList = subwayArrivalInfoList
            .map {
                SubwayArrivalItem(
                    arrInfo = "${it.destinationName}행 ${it.arrInfo}",
                    isUpLine = it.updnLine == "상행" || it.updnLine == "외선"
                )
            }
    )
}

실시간 정보를 불러오면 호선, 상행선/하행선 또는 내선/외선이 구분 없이 위의 데이터들이 묶여서 내려옵니다.

그렇기 때문에 받아온 결과 리스트에 groupBy를 이용하여 호선 단위로 그룹화합니다.

그룹화한 데이터에서 map을 원하는 데이터 클래스 형식으로 가공합니다.

private val _arrivalInfoList = MutableStateFlow<List<SubwayArrival>>(listOf())
val arrivalInfoList: Flow<List<SubwayArrival>> = _arrivalInfoList

/** 지하철 도착 정보 조회 **/
fun fetchRealtimeStationArrivals(
    stationName: String
) {
    repository
        .fetchRealtimeStationArrivals(stationName)
        .onStart { _isProgress.value = true }
        .onEach { _arrivalInfoList.value = it }
        .onCompletion { _isProgress.value = false }
        .catch {
            _arrivalInfoList.value = emptyList()
            it.printStackTrace()
        }
        .launchIn(viewModelScope)
}

도착 정보는 역을 선택할 때마다 계속 데이터가 변경되어야 하는 부분이어서 기존에 사용하던 방법으로 표현을 해 보았습니다.


4) 지하철 목적지 경로 조회

3.2.2 지하철 목적지 경로 조회.png

이 화면에서는 선택한 출발지와 목적지의 경로를 보여줍니다.

평일/토요일/휴일 중 1개와 시간을 선택하여 최적의 경로를 조회합니다.

override fun fetchSubwayRoute(
    searchTime: String,
    startStationCode: String,
    endStationCode: String,
    week: String
): Flow<SubwayRouteInfo> = flow {
    val list = mutableListOf<RouteItem>()
    val numberOfRow = 50
    var pageNumber = 1
    var totalCount: Int? = null
    var arrivalTime = ""
    var fee = 0
    var transferCount = 0

    try {
        while (totalCount == null || (pageNumber - 1) * numberOfRow < totalCount) {
            val result = client.fetchSubwayRoute(
                pageNo = pageNumber,
                numOfRows = numberOfRow,
                searchTime = searchTime,
                startStationCode = startStationCode,
                endStationCode = endStationCode,
                week = week
            )
            arrivalTime = result.data.arrivalTime
            fee = result.data.fee
            list.addAll(result.data.route.map { it.toRouteItem() })
            totalCount = result.totalCount
            transferCount = result.data.transfer
            pageNumber++
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }

    emit(
        SubwayRouteInfo(
            deptTime = list.getOrNull(0)?.time ?: "",
            arrivalTime = formatTime(arrivalTime),
            transferCount = transferCount,
            fee = fee.priceFormat(),
            list = list
        )
    )
}

[서울교통공사_서울 도시철도 목적지 경로 정보] api를 사용하여 목적지 경로를 조회합니다.

해당 api는 버스 노선과 마찬가지로 반복을 통해서 데이터를 가져오는 방식의 api입니다.

버스 노선의 경우 다음 화면에서 사용하는 방식과 동일하게 수정을 할 예정입니다.

그런데 이번의 경우에는 데이터의 구조가 조금 달라서 어떻게 하면 깔끔하게 할지 고민을 하고 있습니다.


5) 지하철 시간표 조회

3.2.3 지하철 시간표 조회.png

이 화면에서는 지하철 시간표를 보여줍니다.

평일/토요일/공휴일 중 하나를 선택해서 시간표를 볼 수 있습니다.

override fun fetchSubwaySchedule(
    stationCode: String,
    week: Int,
    direction: Int
) = flow {
    var start = 1
    val numberOfRow = 100
    var totalCount: Int? = null

    try {
        while (totalCount == null || start < totalCount) {
            val result = client.fetchSubwaySchedule(
                start = start,
                end = start + numberOfRow - 1,
                stationCode = stationCode,
                week = week,
                direction = direction
            )
            totalCount = result.totalCount
            start += numberOfRow
            emit(result.row.map { it.toSubwayScheduleInfo() })
        }
    } catch (e: Exception) {
        e.printStackTrace()
        emit(emptyList())
    }
}

[서울시 역 코드로 지하철역별 열차 시간표 정보 검색] api를 통하여 시간표를 조회합니다.

이 api는 상행 또는 내선으로 1번부터 끝까지 조회하고 하행 또는 외선으로 1번부터 끝까지 조회하여서 화면에 출력해야 합니다.

api에서 결과를 받을때 마다 emit 하여 데이터를 전달합니다.

private fun fetchSubwaySchedules() = viewModelScope.launch {
    originList.clear()
    fetchSubwaySchedule(1)
    fetchSubwaySchedule(2)
}

private suspend fun fetchSubwaySchedule(direction: Int) = repository.fetchSubwaySchedule(
    stationCode = stationCode,
    week = _week.value,
    direction = direction
).collect {
    originList.addAll(it)
}

상행/하행 또는 내선/외선을 각각 호출해 주고 결과를 collect를 통해 originList에 저장해 주었습니다.

// 원본 리스트
private val originList = mutableStateListOf<SubwayScheduleInfo>()
// 지하철 시간표 리스트
val scheduleList: List<Pair<String, List<SubwayScheduleInfo>>>
    get() {
        return originList
            .sortedWith(compareBy({ info -> info.hour }, { info -> info.minute }))
            .groupBy { it.hour }
            .toList()
    }

저장된 원본 리스트를 화면에서 사용할 때에는 get()을 통해 변형된 리스트를 사용하도록 하였습니다.

  1. 전체 리스트를 시간, 분으로 정렬
  2. 시간 단위로 그룹화
  3. 리스트로 변경

originList가 데이터가 추가되면 scheduleList는 그에 맞게 리스트의 내용이 수정됩니다.

이렇게 해서 교통 관련 화면은 1단계 완료하였습니다.

홈 화면에 노출시키기 위해 시간 설정하는 화면은 아직 홈 화면을 어떻게 할지 정하진 못해서 정해 진 후에 진행할 예정입니다.

그 외 추가 기능이나 테스트 진행은 좀 더 프로젝트를 진행 후에 진행하려고 합니다.

728x90

'안드로이드 > 코드' 카테고리의 다른 글

Mj App : 게임 - 엘소드  (0) 2023.06.27
Mj App : 공통  (0) 2023.06.27
Compose 내 관리앱 만들기 : 2. 교통 - 버스  (0) 2023.03.19
Compose 내 관리앱 만들기 : 1. 프로젝트 구성  (0) 2023.03.19
코드 분석  (0) 2023.01.24
댓글