자유로운 개발일지/APP

[APP] 우당탕탕 앱 만들기

그낙이 2025. 5. 10. 23:04
반응형

Kotlin으로 만드는 우당탕탕 버스 도착 알림 앱 [3편]

이 글은 UI 이후 실제 데이터를 연결하기 위한 공공 API 연동과 백엔드 구축을 진행하는 과정입니다. 앞선 글에서 화면 UI를 만들고 입력창까지 구현했다면, 이번 편부터는 진짜 앱처럼 동작하게 만들기 위한 핵심 단계로 들어갑니다. 

 

백엔드 서버 구축하기 

공공 API는 일반적으로 API Key를 사용합니다. 이 키를 앱 안에 직접 넣으면 보안에 취약해지기 때문에, 중간에 백엔드 서버를 두어 API Key를 안전하게 관리하고, 필요한 데이터만 앱에 전달하는 방식으로 구성할 예정입니다. 

[Android 앱]
     │
     ▼
[FastAPI 서버]
     │
     ▼
[공공 데이터 API]

 

저는 Python 기반의 FastAPI를 사용할 예정입니다. 실시간 API 요청에 빠르게 응답하려면 비동기 처리가 가능해야 하는데, FastAPI는 async/await을 통해 이를 지원합니다. 공공 API를 수신하고 앱에 필요한 데이터만 가공해서 주는 역할을 수행할 백엔드가 필요한데, FastAPI만큼 빠르고 가벼운 프레임워크는 없다고 판단했습니다. 

 

FastAPI 시작하기

지난 시간에 제작한 앱과 백엔드 서버를 같은 깃에서 관리해봅시다. 브랜치를 따로 파도 괜찮지만 1인 개발이므로 같은 브랜치에서 진행하도록 하겠습니다.

busalarm/
├── .git/
├── android_app/        ← 안드로이드 앱 코드 (Android Studio 프로젝트)
├── fastapi_backend/    ← FastAPI 서버 코드
└── .gitignore

 

fastapi_backend/ 기본 구조입니다.

fastapi_backend/
├── main.py
├── app/
│   └── routes.py
└── requirements.txt

 

가상환경을 실행하고, 가상환경 안에서 fastapi와 uvicorn을 설치해줍시다. 

 

 

[Linux] Uvicorn

Uvicorn이란? Uvicorn은 ASGI 서버입니다.ASGI(Asynchronous Server Gateway Interface)는 WSGI의 다음 세대로, 비동기 처리를 지원합니다. 즉, FastAPI, Django Channels, 최신 웹 프레임워크를 사용할 때는 ASGI 기반의 서버

gnaaak.tistory.com

python -m venv venv
source venv/Script/activate

pip install fastapi uvicorn python-dotenv
pip freeze > requirements.txt
uvicorn main:app --reload

공공 API 받아오기

간단한 백엔드 서버를 만들었다면 공공 API 정보를 받아옵시다. 알람을 구현하기 위해서는 버스의 도착 정보와 버스 위치 정보 조회가 필요합니다. 공공 데이터 포털에 접속하여 필요한 API 서비스들을 신청합시다.

 

 

공공데이터 포털

국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유•활용할 수 있도록 공공데이터(Datase

www.data.go.kr

 

서울특별시_정류소정보조회 서비스

정류소 정보 조회 서비스는 다음과 같습니다.

getStationByNameList를 사용하면, 정류소 고유 번호를 받아올 수 있습니다. 아래 예시는 강남역을 검색했을 때 나온 XML 중 일부입니다. 

<itemList>
  <arsId>23813</arsId>
  <posX>202660.62587593487</posX>
  <posY>444344.44306580257</posY>
  <stId>122000606</stId>
  <stNm>강남역</stNm>
  <tmX>127.0300921798</tmX>
  <tmY>37.4985037086</tmY>
</itemList>

 

여기서 얻을 수 있는 정보는 다음과 같습니다.

arsId 정류소 고유번호 (조회용) 23813
posX TM 기준 X 좌표 202660.62587593487
posY TM 기준 Y 좌표 444344.44306580257
stId 정류소 ID (시스템 내부 식별용) 122000606
stNm 정류소 이름 강남역
tmX 경도 (Longitude, WGS84) 127.0300921798
tmY 위도 (Latitude, WGS84) 37.4985037086

 

나중에 앱서비스가 고도화되면, 지도를 활용해 근처 정류장을 찾게 할 수도 있겠지만 MVP로 버스 정류장 이름과, 노선을 안다는 가정으로 개발을 진행하기 때문에 여기서 arsId, stId, stNm을 활용할 수 있을 것 같습니다. 

 

다음은 getStationByUidItem에서 사용할 수 있는 정보들입니다. 이외에도 좌표, 환승 여부, 만차 여부 등이 있지만 필요한 정보들만 가져와보도록 합시다. 도착 예정 버스들의 시간과 노선에 대한 정보, 그리고 버스에 대한 정보들만 추려봤습니다.

<itemList>
    <arrmsg1>6분6초후[2번째 전]</arrmsg1>
    <arrmsg2>25분13초후[8번째 전]</arrmsg2>
    <arrmsgSec1>6분6초후[2번째 전]</arrmsgSec1>
    <arrmsgSec2>25분13초후[8번째 전]</arrmsgSec2>
    <arsId>23813</arsId>
    <busRouteAbrv>6020</busRouteAbrv>
    <busRouteId>100100509</busRouteId>
    <deTourAt>00</deTourAt>
    <lastTm>2010 </lastTm>
    <rtNm>6020</rtNm>
    <sectNm>프로비스타호텔.서초동유원아파트~강남역</sectNm>
    <stId>122000606</stId>
    <stNm>강남역</stNm>
    <stationNm1>교대역5번출구</stationNm1>
    <stationNm2>구반포역.세화고등학교</stationNm2>
    <traSpd1>30</traSpd1>
    <traSpd2>28</traSpd2>
    <traTime1>366</traTime1>
    <traTime2>1513</traTime2>
    <vehId1>123060766</vehId1>
    <vehId2>123060863</vehId2>
</itemList>

 

 

arrmsg1 첫 번째 차량 도착 메시지 6분6초후[2번째 전]
arrmsg2 두 번째 차량 도착 메시지 25분13초후[8번째 전]
arrmsgSec1 첫 번째 도착 메시지 (초 기준, 텍스트) 6분6초후[2번째 전]
arrmsgSec2 두 번째 도착 메시지 (초 기준, 텍스트) 25분13초후[8번째 전]
arsId 정류소 고유번호 23813
busRouteAbrv 버스 노선 약칭 6020
busRouteId 버스 노선 ID 100100509
deTourAt 우회 여부 (00: 정상) 00
lastTm 막차 시간 (HHMM) 2010
rtNm 버스 번호 6020
sectNm 현재 차량 주행 구간 프로비스타호텔.서초동유원아파트~강남역
stId 정류소 시스템 ID 122000606
stNm 정류소 이름 강남역
stationNm1 첫 번째 차량 위치 교대역5번출구
stationNm2 두 번째 차량 위치 구반포역.세화고등학교
traSpd1 첫 번째 차량 속도 (km/h) 30
traSpd2 두 번째 차량 속도 (km/h) 28
traTime1 첫 번째 차량 도착까지 남은 시간 (초) 366
traTime2 두 번째 차량 도착까지 남은 시간 (초) 1513
vehId1 첫 번째 차량 ID 123060766
vehId2 두 번째 차량 ID 123060863

 

마지막으로  getRouteByStationList 를 호출해서 정보를 얻어옵시다. 버스, 버스 정류소, 그리고 노선에 대한 정보가 필요한데 getRouteByStationList를 통해서 정류소를 경유하는 노선 정보들을 받아올 수 있습니다. 

<itemList>
    <busRouteAbrv>6020</busRouteAbrv>
    <busRouteId>100100509</busRouteId>
    <busRouteNm>6020</busRouteNm>
    <busRouteType>1</busRouteType>
    <firstBusTm> </firstBusTm>
    <firstBusTmLow> </firstBusTmLow>
    <lastBusTm> </lastBusTm>
    <lastBusTmLow> </lastBusTmLow>
    <length>143</length>
    <nextBus>10</nextBus>
    <stBegin>역삼역</stBegin>
    <stEnd>인천공항</stEnd>
    <term>60</term>
</itemList>

 

busRouteAbrv 버스 노선 약칭 (표기용) 6020
busRouteId 버스 노선 고유 ID 100100509
busRouteNm 버스 노선 번호 6020
busRouteType 노선 유형 (1: 공항, 2: 시내, 3: 마을 등) 1
firstBusTm 첫차 시간 (고상버스)  
firstBusTmLow 첫차 시간 (저상버스)  
lastBusTm 막차 시간 (고상버스)  
lastBusTmLow 막차 시간 (저상버스)  
length 총 노선 거리 (단위: km) 143
nextBus 다음 배차 시간 (단위: 분) 10
stBegin 기점 정류장명 역삼역
stEnd 종점 정류장명 인천공항
term 기본 배차 간격 (단위: 초 or 분) 60

 

검색한 강남역 정류소는 공항으로 이어지는 노선이 1개인 정류소인 것 같습니다.

 

그러면 MVP를 만들기 위한 기본적인 정보는 다 얻은 것 같습니다. 나머지 API들의 경우에는 지금 개발하려고 하는 앱에는 (아직까지는) 과한 기능들인 것 같습니다. 우선 정류소명 검색을 통해 정류소의 asrId를 얻어오고, 이를 통해서 busRouteAbrv(버스 번호)를 얻어왔습니다. 이 두 정보를 조합해서 특정 버스가, 특정 정류소에 언제 도착하는지 확인해보도록 합시다. 


백엔드에서 API 호출하기

앞서 말했듯, API 키를 안전하게 보호하기 위해 백엔드 서버를 구축해줍시다. 백엔드 서버는 앱 화면과 공공데이터의 중간다리 역할을 해줍니다. 다음은 폴더 구조입니다. bus_service에서 앱과 소통하는 로직을, http_client에서는 API 호출 로직을 작성해볼겁니다.

 

현재는 버스 알람을 위해서 API를 2번 호출해야 하는 구조라 DB에 정류소/노선/버스 등의 데이터는 미리 저장해두는게 좋지만 지금은 DB없이 가볍게 진행하도록 하겠습니다. 

fastapi_backend/
├── main.py
├── .env
├── requirements.txt
├── app/
│   ├── routes/
│   │   └── bus.py
│   ├── services/
│   │   └── bus_service.py
│   └── utils/
│       └── http_client.py

 

API키는 .env 파일에 넣어주시면 됩니다. 서비스를 신청하면 인코딩과 디코딩 된 인증키를 서비스 화면에서 보실 수 있습니다. 이중에서 일반 인증키(Decoding)을 넣어줍시다.

# .env

bus_api_key=my_bus_api_key

 

이제 코드를 작성해봅시다. 공공데이터 포털에서 서비스 url과 예시 코드를 볼 수 있습니다.

 

서울특별시_정류소정보조회 서비스

정류소에 대한 정보 제공

www.data.go.kr

http://ws.bus.go.kr/api/rest/stationinfo가 BASE_URL  역할을 해주고, 뒤에 /getStationByName과  /getStationByUi를 넣어주면 개발에 필요한 서비스 url은 전부가 될 것 같습니다. 

서비스마다 url이 다를 수 있으니 꼭 확인바랍니다!

 

다음은 제공해주는 예시 코드입니다. 파이썬 이외에도 Java, JavaScript, C# 등 다양한 샘플 코드가 존재하니 개발하시는 언어에 맞춰서 개발해주시면 되겠습니다. 저는 FastAPI를 활용하기 때문에 파이썬 샘플 코드를 가져왔습니다. 

# Python3 샘플 코드 #


import requests

url = 'http://ws.bus.go.kr/api/rest/stationinfo/getStationByName'
params ={'serviceKey' : '서비스키', 'stSrch' : '경성중고사거리' }

response = requests.get(url, params=params)
print(response.content)

 

routes/bus.py입니다. 

from fastapi import APIRouter
from app.utils.http_client import fetch_bus_station_id, fetch_bus_station_info
import re 

router = APIRouter()


def parse_arrmsg(arrmsg: str):
    """도착 메시지에서 시간과 정거장 수 추출"""
    time_text = re.sub(r"\[.*?\]", "", arrmsg).strip()
    match = re.search(r"\[(\d+)번째 전\]", arrmsg)
    stops_remaining = int(match.group(1)) if match else None
    return time_text, stops_remaining

@router.get("/fetch_bus_info")
async def get_station_by_name(name: str, number: int):
    
    ars_ids = await fetch_bus_station_id("getStationByName", {"stSrch": name})
    bus_arrival = await fetch_bus_station_info("getStationByUid", ars_ids, number)

    formatted_result = []

    for item in bus_arrival:
        time_text, stops = parse_arrmsg(item.get("arrmsg1", ""))
        formatted_result.append({
            "정류장ID": item["arsId"],
            "버스번호": item["bus_no"],
            "현재위치": item.get("stationNm1"),
            "도착예정": time_text,
            "남은정거장": stops,
            "남은시간(초)": item.get("traTime1"),
        })

    return {"결과": formatted_result}

 

아직 앱 화면서 호출한 정보를 돌려주는 로직은 작성하지 않았습니다. 이번 글은 백엔드와 공공API 연동이 목적이니까요. post가 아닌 get 요청을 앱 화면에서 보내(고, 백엔드가 받)는 이유는 노출돼도 보안상 문제가 없기 때문에 쿼리스트링을 활용한 get 요청을 선택했습니다. "조회"이기도 하구요. 정류소를 이름으로 검색할 name과 버스 번호 number를 받았습니다. 

 

다음은 utils/http_client.py 코드입니다. 

# app/utils/http_client.py

import os
import httpx
from dotenv import load_dotenv
import xmltodict

load_dotenv()
API_KEY = os.getenv("BUS_API_KEY")
BASE_URL = "http://ws.bus.go.kr/api/rest/stationinfo/"

async def fetch_bus_station_id(endpoint: str, params: dict):
    if not params:
        raise ValueError("params는 반드시 포함되어야 합니다.")

    full_params = {
        "serviceKey": API_KEY,
        **params
    }

    async with httpx.AsyncClient() as client:
        try:
            res = await client.get(f"{BASE_URL}/{endpoint}", params=full_params)
            res.raise_for_status()
            parsed = xmltodict.parse(res.text)
            items = parsed["ServiceResult"]["msgBody"]["itemList"]

            # 리스트가 아니고 하나만 올 수도 있으니 방어 코드
            if isinstance(items, dict):
                items = [items]
            return [item["arsId"] for item in items]

        except httpx.HTTPError as e:
            print(f"API 호출 오류: {e}")
            return None

 

기본적인 호출 구조입니다. 이제 endpoint에 삼성역을 입력하고 넘어오는 데이터를 봅시다. 로컬에서 FastAPI를 실행하고, 쿼리 스트링으로 요청을 보내면 다음과 같은 결과를 얻을 수 있습니다.

총 11개의 정류장이 검색되었습니다. 이제 이 정류장들을 돌면서 해당 버스번호가 있는지 조회하면 됩니다.

여기서 정류소가 2개 검색되면 지도를 보여주던가 사용자가 선택하게 하긴 해야될 것 같네요. 지도 기능을 추가한다면 현재 위치 기반으로 근처 정류소들을 띄워줄 수 있는 getStaionsByPosList도 사용할 수 있을 것 같습니다. 이건 추후에 진행해보도록 합시다. 

 

계속 이어서 fetch_bus_station_info 함수입니다.

async def fetch_bus_station_info(endpoint: str, ars_Ids: list, number: int):
    if len(ars_Ids) == 0:
        raise ValueError("ars_Ids는 반드시 포함되어야 합니다.")

    bus_arrival = []

    for ars_id in ars_Ids:
        full_params = {
            "serviceKey": API_KEY,
            "arsId": ars_id,
        }
        async with httpx.AsyncClient() as client:

            try:
                res = await client.get(f"{BASE_URL}/{endpoint}", params=full_params)
                res.raise_for_status()
                parsed = xmltodict.parse(res.text)
                item_list = parsed["ServiceResult"]["msgBody"].get("itemList", [])

                if isinstance(item_list, dict):  # 단일 응답일 때
                    item_list = [item_list]

                for item in item_list:
                    if item.get("busRouteAbrv") == str(number):
                        bus_arrival.append({
                            "arsId": ars_id,
                            "bus_no": item["busRouteAbrv"],
                            "arrmsg1": item.get("arrmsg1"),
                            "stationNm1": item.get("stationNm1"),
                            "traTime1": item.get("traTime1"),
                        })

            except httpx.HTTPError as e:
                print(f"API 호출 오류 (arsId: {ars_id}): {e}")
                continue  # 실패해도 다음 arsId로 넘어감

    return bus_arrival

 

삼성역, 4319 검색 결과는 아래와 같이 나옵니다. 도착 1번째, 2번째 버스가 나오고, 각각의 버스 위치 예정 시간, 남은 정거장이 보입니다. 다행히 제가 퇴근하는 곳에서는 삼성역으로 검색했을 때 4319 버스가 지나가는 정류장이 1군데네요. 차차 고도화 하기로 하고 일단은 이대로 넘어가줍시다. 

{
  "결과": [
    {
      "정류장ID": "23242",
      "버스번호": "4319",
      "현재위치": "대치동미도아파트",
      "도착예정": "7분6초후",
      "남은정거장": 4,
      "남은시간(초)": "426"
    },
    {
      "정류장ID": "23438",
      "버스번호": "4319",
      "현재위치": "삼성역3번출구",
      "도착예정": "곧 도착",
      "남은정거장": null,
      "남은시간(초)": "23"
    }
  ]
}

 

이제 이 데이터를 앱 화면에 보이고, FCM(Firebase Cloud Messaging)을 이용한 푸쉬 알람 기능과 ForegroundService를 통해 실시간으로 상단바에 표시하는 과정을 거치면 됩니다. 

 

 

다음 글 안내

 

이번 글에서는 공공 API를 연동해서 백엔드에 버스 도착 정보를 받아오는 작업을 완료했습니다. 이제 이 데이터를 앱 화면에 전송만 해주면 아주 기본적인 출퇴근 도우미가 완성됩니다. 다음 글에서는 FCM (Firebase Cloud Messaging)을 이용한 푸쉬 알람 기능과 ForegroundService를 통해 실시간으로 상단바에 표시하는 과정을 다룰 예정입니다. 또한 로그인 기능은 사용하지 않는 MVP이기 때문에 로컬에 즐겨찾기 한 [정류장, 버스]를 저장하는 과정을 진행할 예정입니다.

 

이번 편이 앱의 기능 구현이였다면, 다음 편부터는 실사용을 위한 핵심 기능들이 하나씩 현실이 되어갑니다.

 

[APP] 우당탕탕 앱 만들기

Kotlin으로 만드는 우당탕탕 버스 도착 알림 앱 [4편]이 글은 프론트와 백엔드 API 연동을 진행하는 과정입니다. 앞선 글에서 화면 UI, 백엔드에서 공공API로 데이터를 받아왔다면 이제 프론트와 백

gnaaak.tistory.com

 

 

 

반응형