LLM 엔지니어링14장 · Streamlit 화면과 Docker 컨테이너16 / 22← 이전목차다음 →MetaContents

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 서버 띄우기

Python
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 를 부르는 화면 쓰기

Python
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단계. 화면으로 예측하기

Python
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 를 끄고 화면 다시 열기

Python
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. 선택 상자그려진 선택 상자 수: 0st.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).
← 이전 절14장 · 실습 2 — 채팅 화면과 대화 기억다음 절 →14장 · 실습 4 — 화면과 API 를 실제 서버로 띄우기