14장. Streamlit 화면과 Docker 컨테이너
화면과 API 잇기
홀 직원은 요리를 하지 않고 주문을 주방에 넘깁니다. 주방이 바쁘거나 문을 닫았으면 손님에게 "지금은 주문을 받을 수 없습니다"라고 알립니다. 화면도 모델을 직접 들고 있지 않고 API 에 요청을 보내며, API 가 응답하지 않으면 그 사실을 사용자에게 알립니다.
1번에서 화면이 입력을 받습니다. 2번에서 그 입력을 JSON 으로 API 에 보냅니다. 3번에서 API 가 검사하고 예측합니다. 4번에서 화면이 결과를 그리고, API 가 꺼져 있거나 시간 안에 답하지 않으면 오류 알림을 그립니다. 여기서 홀 직원이 화면, 주방이 API 입니다.
모델을 화면에 넣을까, API 를 부를까
| 견줄 것 | 화면이 모델을 직접 부른다(실습 1) | 화면이 API 를 부른다(실습 3) |
|---|---|---|
| 만들기 | 파일 하나로 끝난다 | 서버 둘(화면, API) |
| 다른 앱도 쓰나 | 못 쓴다 | 같은 API 를 쓴다 |
| 모델 바꾸기 | 화면도 다시 띄운다 | API 만 바꾼다 |
| 화면 서버에 필요한 것 | 모델과 scikit-learn, torch 등 | 화면 라이브러리만 |
| 고장 | 하나가 멈추면 모두 멈춘다 | 화면은 떠 있고 "API 연결 실패"를 알린다 |
API 주소는 설정으로
API 의 주소는 실행하는 곳마다 다릅니다. 내 PC 에서는 http://127.0.0.1:8013, docker compose 안에서는 http://api:8013 입니다. 그래서 코드에 적지 않고 환경 변수로 받습니다.
import os, urllib.request
API = os.environ.get("API_URL", "http://127.0.0.1:1") # 설정이 없으면 기본값(여기서는 일부러 닫힌 1번 포트)
try:
urllib.request.urlopen(API + "/health", timeout=2)
except OSError as e: # 연결 거부, 시간 초과는 모두 OSError 로 잡힌다
print("API 에 연결할 수 없습니다:", type(e).__name__)코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
os.environ.get("API_URL", 기본값) | environment(환경) 변수 가운데 API_URL 을 읽는다. 없으면 기본값을 쓴다 |
urllib.request.urlopen(주소, timeout=2) | URL(Uniform Resource Locator, 인터넷 주소)을 열어 요청을 보낸다. 2초 안에 답이 없으면 멈추고 오류를 낸다 |
"/health" | API 가 살아 있는지 확인하는 주소다. health 는 "건강"이라는 뜻이다 |
except OSError as e: | OS(Operating System, 운영체제)가 알리는 오류를 잡는다. 연결 거부와 시간 초과가 여기에 든다 |
type(e).__name__ | 잡힌 오류의 종류 이름을 글자로 꺼낸다 |
이 코드가 뜻하는 것
홀 직원이 주방에 전화를 거는 일과 같습니다. 주방 번호는 가게마다 달라서 수첩(환경 변수)에서 찾고, 수첩에 없으면 기본 번호로 겁니다. 2초 동안 아무도 받지 않으면 끊고 손님에게 알립니다. 여기서 주방 번호가 API 주소, 수첩이 환경 변수, 2초가 timeout 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
API = os.environ.get(...) | API 주소를 설정에서 읽는다. 여기서는 일부러 닫힌 1번 포트가 기본값이다 | 수첩에서 주방 번호를 찾는다 |
urllib.request.urlopen(...) | /health 로 요청을 보낸다 | 주방에 전화를 건다 |
except OSError as e: | 연결이 안 되면 잡는다 | 받지 않으면 끊는다 |
print("API 에 연결할 수 없습니다:", ...) | 오류 종류와 함께 알린다 | 손님에게 "지금은 주문을 받을 수 없습니다" |
결과의 URLError 는 주소로 연결하지 못했다는 오류입니다. 화면은 멈추지 않고 이 알림을 그리면 됩니다. 비유와 다른 점은, 전화는 신호가 가다 끊기지만 닫힌 포트는 기다리지 않고 바로 거절된다는 것입니다.
작게 줄여서 눈으로 보기
환경 변수가 있을 때와 없을 때 읽히는 값이 어떻게 달라지는지 봅니다. DEMO_API_URL 은 설명을 위해 지은 이름이고, 이 예는 지금 실행 중인 파이썬 안에서만 값을 바꿉니다.
import os, urllib.error
print(os.environ.get("DEMO_API_URL", "http://127.0.0.1:8013")) # 설정이 없으니 기본값
os.environ["DEMO_API_URL"] = "http://api:8013" # compose 가 넣어 준다고 치자
print(os.environ.get("DEMO_API_URL", "http://127.0.0.1:8013")) # 이제 설정한 값
print(issubclass(urllib.error.URLError, OSError), issubclass(TimeoutError, OSError))http://127.0.0.1:8013
http://api:8013
True True| 결과 | 읽는 법 |
|---|---|
http://127.0.0.1:8013 | 설정이 없어 기본값을 읽었다. 내 PC 에서 띄울 때다 |
http://api:8013 | 설정을 넣은 뒤에는 그 값을 읽었다. compose 안에서 띄울 때다 |
True True | issubclass 는 "이 오류가 저 오류의 한 갈래인가"를 묻는다. 연결 실패(URLError)와 시간 초과(TimeoutError)가 모두 OSError 의 갈래라서 except OSError 하나로 둘 다 잡힌다 |
코드는 그대로 두고 띄우는 곳의 설정만 바꾸면 주소가 바뀝니다. 그래서 같은 화면 코드를 내 PC 와 컨테이너에서 함께 씁니다.
주의할 점
- 요청에는 늘 시간 제한(
timeout)을 줍니다. 주지 않으면 API 가 멈췄을 때 화면도 한없이 기다립니다. - 화면 서버에 API 키가 필요하면 화면 코드에 적지 않고 환경 변수나 Streamlit 의
secrets.toml에 둡니다. 브라우저로 보내는 화면에는 키를 넣지 않습니다(프로젝트).