14장. Streamlit 화면과 Docker 컨테이너
실습 3 — 화면을 API 와 잇기
13장의 예측 API(out_delay_api.py)를 서버로 띄우고, 그 API 를 부르는 화면 out_app_api.py 를 만듭니다. 화면은 모델을 들고 있지 않고, 선택 상자의 보기도 API 의 명세(/openapi.json)에서 읽어 옵니다. API 가 꺼졌을 때 화면이 어떻게 알리는지도 봅니다. 13장 실습 1, 2를 먼저 실행해야 합니다.
1단계. API 서버 띄우기
import json, os, subprocess, sys, time, urllib.request
from pathlib import Path
# 13차시 실습 4처럼 uvicorn 으로 띄운다. 주소는 127.0.0.1 로 적는다(13차시)
api = subprocess.Popen([sys.executable, "-m", "uvicorn", "out_delay_api:app", "--port", "8013", "--log-level", "warning"])
for _ in range(60):
try:
print(urllib.request.urlopen("http://127.0.0.1:8013/health").read().decode())
break
except OSError:
time.sleep(0.5){"상태":"ok","판":"delay-lr-1"}2단계. API 를 부르는 화면 쓰기
Path("out_app_api.py").write_text('''"""배송 지연 예측 화면(API 연결). 실행: streamlit run out_app_api.py"""
import json
import os
import urllib.error
import urllib.request
import streamlit as st
API = os.environ.get("API_URL", "http://127.0.0.1:8013") # 어디서 띄우느냐에 따라 바뀌는 주소
def call(path, body=None, timeout=5):
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r: # 시간 제한을 꼭 준다
return json.loads(r.read())
@st.cache_data(ttl=60) # 보기 목록은 1분 동안 저장해 두고 다시 묻지 않는다
def choices():
props = call("/openapi.json")["components"]["schemas"]["Order"]["properties"]
return {k: v["enum"] for k, v in props.items() if "enum" in v}
st.title("배송 지연 예측")
try:
health = call("/health")
opts = choices()
except OSError: # 연결 거부, 시간 초과
st.error(f"API 서버({API})에 연결할 수 없습니다. 서버가 켜져 있는지 확인하세요.")
st.stop() # 여기서 스크립트를 멈춘다. 아래 칸은 그리지 않는다
st.caption(f"API 연결됨 · 모델 판 {health['판']}")
with st.form("order"):
order = {k: st.selectbox(k, v, key=k) for k, v in opts.items()}
order["주문시각"] = st.slider("주문시각", 0, 23, 14, key="주문시각")
order["거리km"] = st.number_input("거리km", 0.5, 1000.0, 20.0, key="거리km")
order["무게kg"] = st.number_input("무게kg", 0.0, 100.0, 2.0, key="무게kg")
sent = st.form_submit_button("예측하기")
if sent:
try:
result = call("/predict", order)
except urllib.error.HTTPError as e: # API 가 4xx, 5xx 로 거절했을 때
st.error(f"요청이 거절됐습니다({e.code}).")
st.stop()
st.metric("지연 확률", f"{result['지연확률']:.1%}")
(st.warning if result["지연예상"] else st.success)("지연 예상" if result["지연예상"] else "제때 도착 예상")
''', encoding="utf-8")
print("화면 파일:", Path("out_app_api.py").stat().st_size, "바이트")화면 파일: 2254 바이트3단계. 화면으로 예측하기
from streamlit.testing.v1 import AppTest
os.environ["API_URL"] = "http://127.0.0.1:8013"
at = AppTest.from_file(str(Path("out_app_api.py").resolve()), default_timeout=60).run()
print(at.caption[0].value)
print("API 에서 읽어 온 보기:", {s.label: s.options for s in at.selectbox})
for key, value in {"지역": "지방", "상품분류": "도서", "날씨": "눈", "택배사": "빠른별택배"}.items():
at.selectbox(key=key).set_value(value)
at.slider(key="주문시각").set_value(22)
at.number_input(key="거리km").set_value(197.6)
at.number_input(key="무게kg").set_value(4.6)
at.button[0].click().run()
print(at.metric[0].value, [w.value for w in at.warning])API 연결됨 · 모델 판 delay-lr-1
API 에서 읽어 온 보기: {'지역': ['서울', '경기', '인천', '지방'], '상품분류': ['의류', '생활용품', '식품', '도서', '가전'], '날씨': ['맑음', '흐림', '비', '눈', '모름'], '택배사': ['한결택배', '빠른별택배', '새벽길택배']}
68.9% ['지연 예상']4단계. API 를 끄고 화면 다시 열기
api.terminate()
api.wait(timeout=10)
at2 = AppTest.from_file(str(Path("out_app_api.py").resolve()), default_timeout=60).run()
print("오류 알림:", [e.value for e in at2.error])
print("그려진 선택 상자 수:", len(at2.selectbox))오류 알림: ['API 서버(http://127.0.0.1:8013)에 연결할 수 없습니다. 서버가 켜져 있는지 확인하세요.']
그려진 선택 상자 수: 0이 실습이 뜻하는 것
이 실습은 매장 계산대가 판단할 일을 본사에 전화로 묻는 것과 같습니다. 먼저 본사 상담실의 문을 열고(1단계), 판단 책 없이 본사에 전화만 하는 매장 화면을 짭니다(2단계). 손님의 주문을 본사에 물어 답을 받아 보이고(3단계), 본사 전화가 끊긴 날에는 화면이 "연결할 수 없다"고 알립니다(4단계). 여기서 본사가 13장의 예측 API(8013 번 포트), 전화가 /predict 같은 주소로 보내는 요청, 매장 화면이 out_app_api.py 입니다. 비유와 다른 점은, 이 화면은 손님에게 보일 보기 목록까지 본사의 명세(/openapi.json)에서 받아 온다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 서버 띄우기 | {"상태":"ok","판":"delay-lr-1"} | API 서버가 켜져 상태 확인에 답했습니다. 모델의 이름표도 함께 왔습니다 |
| 2. 화면 파일 | 2254 바이트 | API 를 부르는 화면 파일입니다. 실습 1과 달리 모델을 불러오는 줄이 없습니다 |
| 3. 연결 | API 연결됨 · 모델 판 delay-lr-1 | 화면이 API 와 이어졌습니다 |
| 3. 보기 목록 | 지역 4개, 상품분류 5개, 날씨 5개, 택배사 3개 | 보기를 API 의 명세에서 읽어 왔습니다. 화면 코드에는 보기가 한 줄도 적혀 있지 않습니다 |
| 3. 예측 | 68.9% ['지연 예상'] | 실습 1과 같은 주문에 같은 숫자입니다. 모델이 화면 안에 있든 API 뒤에 있든 답은 같습니다 |
| 4. API 끄기 | API 서버(…)에 연결할 수 없습니다. … | API 가 꺼지자 화면이 파이썬 오류 대신 사용자가 할 일을 알려 주는 말을 보였습니다 |
| 4. 선택 상자 | 그려진 선택 상자 수: 0 | st.stop() 으로 멈춰, 쓸 수 없는 칸을 그리지 않았습니다 |
한 줄로 줄이면, 화면은 보여 주는 일만 하고 판단은 API 가 맡도록 나눠도 답이 그대로라는 것을 보인 실습입니다. 이렇게 나눠 두면 실습 5에서 화면 폴더에 모델 파일과 scikit-learn 을 넣지 않아도 됩니다. 이번 확인은 AppTest 안에서 한 것이라, 사람이 브라우저로 여는 실제 서버는 실습 4에서 띄웁니다.
코드 설명
choices()는 API 의/openapi.json에서Literal로 정한 칸의 보기(enum)를 읽어 선택 상자를 채웁니다. 그래서 화면 코드에는 지역, 상품분류 같은 보기가 한 줄도 없고, API 의 규칙이 바뀌면 화면도 따라 바뀝니다.@st.cache_data(ttl=60)로 1분 동안은 다시 묻지 않습니다.- 3단계에서 화면은 모델 없이 API 를 불러 실습 1과 같은 68.9% 와 "지연 예상"을 그렸습니다. 같은 모델이므로 화면이 모델을 직접 부르든 API 를 부르든 결과가 같습니다.
- 4단계에서 API 를 끄고 화면을 다시 열자 빨간 오류 상자에 "API 서버(http://127.0.0.1:8013)에 연결할 수 없습니다"가 나왔고, 선택 상자는 하나도 그려지지 않았습니다.
st.stop()이 그 자리에서 스크립트를 멈췄기 때문입니다. 화면이 멈추거나 파이썬 오류를 그대로 보이는 대신, 사용자가 할 일을 알려 줍니다. os.environ.get("API_URL", 기본값)으로 주소를 받아, 같은 화면 파일을 내 PC 에서는127.0.0.1로, docker compose 에서는api로 띄울 수 있게 합니다(실습 6).