서울 열린데이터광장의 서울시 공공자전거 실시간 대여정보 API를 호출해 대여소별 자전거 현황을 수집하고, Pandas로 데이터프레임을 만든 뒤 Folium 지도에 대여소 위치를 표시한다.
이 데이터는 호출 시점의 현황을 제공하는 실시간 데이터다. 같은 코드를 실행해도 시간에 따라 대여 가능 자전거 수와 거치율이 달라질 수 있다.
분석 흐름은 다음과 같다.
서울 열린데이터광장 API 요청
-> JSON 응답 확인
-> 페이지 단위로 전체 데이터 수집
-> DataFrame 변환 및 자료형 정리
-> Folium 지도에 대여소 위치와 자전거 수 표시
서울 열린데이터광장은 서울시가 제공하는 공공데이터 플랫폼이다. 교통, 환경, 안전, 복지, 문화 등 다양한 데이터를 API와 파일 형태로 제공한다.
공공자전거 실시간 대여정보를 사용하려면 다음 순서로 준비한다.
서울시 공공자전거 실시간 대여정보를 검색한다.노트북이나 Git 저장소에 인증키를 직접 넣으면 키가 외부에 노출될 수 있다. 환경 변수로 관리하면 코드와 비밀 정보를 분리할 수 있다.
import os
API_KEY = os.environ["SEOUL_OPEN_API_KEY"]
macOS 또는 Linux 터미널에서는 다음처럼 현재 터미널 세션에 환경 변수를 설정할 수 있다.
export SEOUL_OPEN_API_KEY="발급받은_인증키"
인증키가 이미 공개된 상태라면 해당 키를 폐기하거나 재발급받는 것이 좋다.
import os
import folium
import pandas as pd
import requests
| 라이브러리 | 역할 |
|---|---|
requests | HTTP 요청을 보내 API 데이터를 받는다. |
pandas | JSON 목록을 표 형태의 DataFrame으로 정리한다. |
folium | 위도와 경도를 사용해 인터랙티브 지도를 만든다. |
os | 환경 변수에서 인증키를 읽는다. |
설치되어 있지 않다면 다음 명령어를 사용한다.
python -m pip install requests pandas folium
서울 열린데이터광장 API의 요청 주소는 인증키, 응답 형식, 서비스 이름, 시작 행, 끝 행을 포함한다.
http://openapi.seoul.go.kr:8088/
{인증키}/json/bikeList/{시작번호}/{끝번호}/
먼저 1~5행만 요청해 응답 구조를 확인한다.
API_KEY = os.environ["SEOUL_OPEN_API_KEY"]
BASE_URL = "http://openapi.seoul.go.kr:8088"
url = (
f"{BASE_URL}/{API_KEY}/json/"
"bikeList/1/5/"
)
response = requests.get(url, timeout=10)
response.raise_for_status()
json_data = response.json()
json_data
requests.get()은 API에 HTTP GET 요청을 보낸다. timeout=10은 서버 응답이 너무 오래 걸릴 때 무한정 기다리지 않도록 제한하는 값이다. raise_for_status()는 HTTP 상태 코드가 200이 아닐 때 예외를 발생시켜 요청 실패를 빠르게 확인하게 한다.
노트북의 실행 결과에서는 다음과 같은 구조를 확인할 수 있다.
{
"rentBikeStatus": {
"list_total_count": 5,
"RESULT": {
"CODE": "INFO-000",
"MESSAGE": "정상 처리되었습니다."
},
"row": [
{
"stationName": "102. 망원역 1번출구 앞",
"parkingBikeTotCnt": "4",
"stationLatitude": "37.55564880",
"stationLongitude": "126.91062927"
}
]
}
}
딕셔너리 인덱싱을 여러 번 사용하면 중간 키가 없을 때 KeyError가 발생한다. .get()을 사용하면 키가 없을 때 기본값을 반환하도록 만들 수 있다.
result_code = (
json_data
.get("rentBikeStatus", {})
.get("RESULT", {})
.get("CODE")
)
print(result_code) # INFO-000
노트북에서 확인한 INFO-000은 정상 처리 상태다. 반대로 응답 구조가 예상과 다르거나 오류 코드가 반환되면 row를 읽기 전에 오류 메시지를 확인해야 한다.
rentBikeStatus 안에는 전체 행 수, 처리 결과, 실제 대여소 목록이 들어 있다.
| 키 | 의미 |
|---|---|
list_total_count | 현재 응답 기준으로 조회 가능한 전체 데이터 수다. |
RESULT | 요청 처리 상태 코드와 메시지다. |
row | 대여소별 실시간 정보 목록이다. |
각 row에는 다음과 같은 정보가 포함된다.
| 컬럼 | 의미 |
|---|---|
stationId | 대여소 식별자다. |
stationName | 대여소 이름이다. |
rackTotCnt | 대여소의 전체 거치대 수다. |
parkingBikeTotCnt | 현재 대여소에 거치된 자전거 수다. |
shared | 전체 거치대 대비 현재 거치 자전거의 비율이다. |
stationLatitude | 대여소 위도다. |
stationLongitude | 대여소 경도다. |
API 응답의 수치 값은 문자열로 제공될 수 있으므로, 계산이나 지도 표시 전에 숫자 자료형으로 변환하는 과정이 필요하다.
API는 한 번에 가져올 수 있는 행 수가 제한될 수 있다. 따라서 1~1000, 1001~2000처럼 범위를 나누어 반복 요청하고, 받은 데이터를 하나의 DataFrame으로 합친다.
def fetch_bike_data(page_size=1000):
api_key = os.environ["SEOUL_OPEN_API_KEY"]
base_url = "http://openapi.seoul.go.kr:8088"
start = 1
data_frames = []
while True:
end = start + page_size - 1
url = (
f"{base_url}/{api_key}/json/"
f"bikeList/{start}/{end}/"
)
response = requests.get(url, timeout=10)
response.raise_for_status()
json_data = response.json()
bike_status = json_data.get("rentBikeStatus", {})
result = bike_status.get("RESULT", {})
result_code = result.get("CODE")
if result_code != "INFO-000":
message = result.get("MESSAGE", "알 수 없는 오류")
print(f"수집 종료: {result_code} - {message}")
break
rows = bike_status.get("row", [])
if not rows:
break
data_frames.append(pd.DataFrame(rows))
total_count = int(bike_status["list_total_count"])
if end >= total_count:
break
start = end + 1
if not data_frames:
return pd.DataFrame()
return pd.concat(data_frames, ignore_index=True)
bike_data_df = fetch_bike_data()
print(bike_data_df.shape)
print(bike_data_df.head())
노트북 실행 시점에는 총 2,744개 대여소 행이 수집되었다. 실시간 데이터이므로 실행 시점에 따라 행 수와 각 대여소의 자전거 수는 달라질 수 있다.
list_total_count를 확인하면 필요한 범위까지만 요청할 수 있다.timeout과 상태 코드 확인이 필요하다.위도와 경도는 Folium 지도에서 숫자로 사용해야 한다. pd.to_numeric()을 사용하면 숫자로 변환할 수 없는 값은 NaN으로 처리할 수 있다.
numeric_columns = [
"rackTotCnt",
"parkingBikeTotCnt",
"shared",
"stationLatitude",
"stationLongitude",
]
for column in numeric_columns:
bike_data_df[column] = pd.to_numeric(
bike_data_df[column],
errors="coerce",
)
bike_data_df = bike_data_df.dropna(
subset=["stationLatitude", "stationLongitude"]
)
이후 기본 정보를 확인한다.
print(bike_data_df.info())
print(bike_data_df.isna().sum())
print(bike_data_df.describe())
astype(float)도 사용할 수 있지만, 잘못된 문자열이 하나라도 있으면 변환 과정에서 바로 오류가 발생한다. 공공데이터처럼 입력 형식을 완전히 신뢰하기 어려운 경우에는 pd.to_numeric(errors="coerce")가 더 안전하다.
Folium은 Leaflet.js를 기반으로 HTML 형태의 인터랙티브 지도를 만든다. 지도 중심은 모든 대여소의 평균 위도와 평균 경도로 설정한다.
bike_map = folium.Map(
location=[
bike_data_df["stationLatitude"].mean(),
bike_data_df["stationLongitude"].mean(),
],
zoom_start=12,
)
각 대여소에 마커를 추가하고, 마커를 클릭했을 때 대여소 이름과 현재 거치 자전거 수가 보이도록 팝업을 만든다.
for _, row in bike_data_df.iterrows():
popup_text = (
f"{row['stationName']}<br>"
f"현재 거치 자전거: {row['parkingBikeTotCnt']}대"
)
folium.Marker(
location=[
row["stationLatitude"],
row["stationLongitude"],
],
popup=folium.Popup(popup_text, max_width=300),
).add_to(bike_map)
bike_map
Jupyter Notebook에서는 마지막 줄의 bike_map을 실행하면 지도가 바로 표시된다. HTML 파일로 저장하면 브라우저에서 독립적으로 열 수 있다.
bike_map.save("seoul_bike_map.html")
대여소가 수천 개이면 일반 마커를 모두 표시할 때 지도 렌더링이 느려질 수 있다. 이때는 작은 원 모양의 CircleMarker를 사용하거나, 가까운 마커를 묶어 주는 MarkerCluster를 적용할 수 있다.
from folium.plugins import MarkerCluster
marker_cluster = MarkerCluster().add_to(bike_map)
for _, row in bike_data_df.iterrows():
folium.Marker(
location=[row["stationLatitude"], row["stationLongitude"]],
popup=row["stationName"],
).add_to(marker_cluster)
parkingBikeTotCnt가 낮다고 해서 해당 대여소의 운영 상태가 항상 나쁘다는 뜻은 아니다. 출퇴근 시간처럼 이용 수요가 높은 시간대에는 대여가 활발해서 자전거 수가 적을 수 있다. 반대로 자전거 수가 많아도 반납이 몰린 결과일 수 있다.
실제 분석에서는 한 시점의 값만 보기보다, 일정 간격으로 수집한 데이터를 쌓아 다음과 같은 질문을 분석하는 편이 더 의미 있다.
RESULT 상태 코드와 row 목록을 확인한 뒤 데이터를 처리해야 한다.pd.concat()으로 합치면 전체 대여소 정보를 DataFrame으로 만들 수 있다.