7장. 웹 UI 연동과 배포
프로젝트 1 — 배포 점검
구름다리여행 약관 챗봇을 Streamlit Community Cloud 에 올리기 전에, 배포할 폴더가 준비되었는지 자동으로 점검하는 스크립트를 만들려고 합니다. 실습 6의 out_ch07_deploy/ 와, 일부러 실수를 넣은 out_ch07_deploy_bad/ 를 함께 점검해 결과를 out_ch07_deploy_check.csv 로 남기세요. 점검은 파일만 읽고 AppTest 를 연습 모드로 돌리므로 API 키가 없어도 됩니다. 실습 6을 먼저 해서 out_ch07_deploy/ 가 있어야 합니다. 약관은 연습용으로 지어낸 가상 문서입니다.
| 항목 | 내용 |
|---|---|
| 점검할 폴더 | out_ch07_deploy/(실습 6), out_ch07_deploy_bad/(1단계에서 만든다) |
| 일부러 넣은 실수 | 앱 파일에 키 꼴의 글을 적은 줄, requirements.txt 에서 빠진 pypdf·faiss-cpu, 없는 .gitignore, 견본 대신 진짜 이름의 secrets.toml |
| 점검 항목 | 꼭 있어야 할 파일, 패키지 목록, 코드에 적힌 키, .gitignore, 비밀 값 견본, 첫 화면, 질문 하나 |
| 결과 파일 | out_ch07_deploy_check.csv(폴더, 점검, 결과, 까닭) |
요구사항은 다섯 가지입니다.
out_ch07_deploy/를 복사해 실수 넷을 넣은out_ch07_deploy_bad/를 만듭니다.- 파일만 읽는 점검 다섯 가지를 함수로 만듭니다. 꼭 있어야 할 파일(
app.py,requirements.txt,travel_terms.pdf,.gitignore), 앱이 쓰는 패키지가requirements.txt에 모두 있는지, 코드와 설정 파일에 키 꼴의 글(sk-로 시작하는 긴 글)이 없는지,.gitignore가.env와.streamlit/secrets.toml을 막는지, 비밀 값은 견본(secrets.toml.example)만 있는지입니다. AppTest로 돌려 보는 점검 두 가지를 만듭니다. 첫 화면이 오류 없이 뜨는지, 질문 하나에 답과 근거 조항이 나오는지입니다. 진짜 모델을 부르지 않도록 연습 모드로 돌립니다.- 두 폴더를 점검해 한 표로 모으고
out_ch07_deploy_check.csv로 저장합니다. - 표를 보고 두 폴더가 배포할 수 있는 상태인지, 고칠 것은 무엇인지 두세 문장으로 적습니다.
단계별 힌트입니다.
- 폴더 복사는
shutil.copytree(원래, 새것, dirs_exist_ok=True)입니다. 실수를 넣기 전에shutil.rmtree로 지난번 것을 지우면 몇 번을 돌려도 같은 결과가 납니다. - 앱이 쓰는 패키지는
ast.parse로 앱 코드를 읽어import줄의 맨 앞 이름(langchain_community등)을 모으고, 패키지 이름(langchain-community)으로 바꿉니다.PyPDFLoader가 쓰는pypdf,FAISS가 쓰는faiss-cpu는import에 안 보이므로 따로 더합니다. - 키 꼴의 글은
re.search(r"sk-[A-Za-z0-9_-]{20,}", 글)로 찾습니다.{20,}은 "20글자 이상"입니다. AppTest를 열 때at.secrets["OPENAI_API_KEY"] = "발급받은_인증키"를 넣으면 앱이 연습 모드가 됩니다. 시험 전에st.cache_resource.clear()로 캐시를 비웁니다.- 점검마다
{"폴더": …, "점검": …, "결과": "통과" 또는 "고칠 것", "까닭": …}사전을 만들어 목록에 모으면pd.DataFrame하나로 표가 됩니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
| 채점 기준 | 배점 |
|---|---|
| 실수를 넣은 폴더를 바르게 만들었다 | 15 |
| 파일 점검 다섯 가지가 맞게 통과와 고칠 것을 가린다 | 35 |
| AppTest 점검 두 가지를 연습 모드로 돌렸다 | 20 |
| 두 폴더의 결과를 한 표로 모아 저장했다 | 15 |
| 결론이 표의 결과와 맞다 | 15 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 상자 아래의 글은 그 단계에서 쓰는 핵심 코드입니다. 2단계는 파일을 읽기만 하는 점검이라 빠르고, 3단계는 앱을 실제로 돌려 보는 점검이라 몇 초 걸립니다. 4단계에서 두 폴더의 결과를 한 표에 모읍니다.
실제로 배포하려면
점검을 모두 통과한 out_ch07_deploy/ 를 실제로 올리는 차례입니다. 이 교안을 만들 때는 GitHub 와 Streamlit Community Cloud 에 올리지 않았고, 아래 화면 이름은 Streamlit 공식 문서(docs.streamlit.io)의 배포 안내에서 읽은 것이라 실제 화면과 대조하지 못했습니다. 화면의 이름이 다르면 공식 문서의 최신 안내를 따르세요. 무료로 쓸 수 있는 조건과 한도도 공식 문서에서 확인합니다.
(실행하지 않은 안내. 화면에서 하는 일)
1. GitHub 에 빈 저장소를 만들고, 본문 "배포 과정" 절의 git 명령으로 out_ch07_deploy 폴더를 올린다
- 올린 뒤 GitHub 저장소 화면에서 .env 와 .streamlit/secrets.toml 이 없는지 눈으로 확인한다
2. share.streamlit.io 에 GitHub 계정으로 로그인한다
3. 작업 공간 오른쪽 위의 "Create app" 을 누르고, 이미 만든 앱이 있다고 고른다
4. Repository(저장소), Branch(가지: main), Main file path(앱 파일: app.py) 를 고른다
- App URL 칸에서 주소의 앞부분을 정할 수 있다(예: gureum-docbot)
5. "Advanced settings" 를 열어
- Python version 에서 내 PC 와 같거나 가까운 판을 고른다
- Secrets 칸에 secrets.toml 의 내용을 붙여 넣는다
OPENAI_API_KEY = "발급받은_인증키" ← 여기에 진짜 키
OPENAI_MODEL = "gpt-4.1-mini"
- 저장한다
6. 배포 단추를 누르고, 설치 로그가 끝날 때까지 기다린다
7. https://gureum-docbot.streamlit.app 꼴의 주소로 들어가 본문 "최종 동작 테스트" 절의 점검표를 하나씩 확인한다
8. 앱을 고치면 같은 저장소에 git push 한다. Community Cloud 가 새 코드를 받아 다시 띄운다수업용 키로 앱을 계속 공개해 두면 주소를 아는 누구의 질문에도 요금이 붙습니다. 확인이 끝나면 앱 설정에서 앱을 지우거나 Secrets 의 키를 빼 두세요.
결과 보기 (예시 풀이)
1단계. 실수를 넣은 폴더 만들기
import shutil
from pathlib import Path
GOOD = Path("out_ch07_deploy")
BAD = Path("out_ch07_deploy_bad")
# 지난번에 만든 것을 지우고 실습 6의 폴더를 그대로 복사한다
shutil.rmtree(BAD, ignore_errors=True)
shutil.copytree(GOOD, BAD)
# 실수 1: 앱 파일 맨 위에 키 꼴의 글을 적은 줄(0 으로 채운 가짜 값)
app = (BAD / "app.py").read_text(encoding="utf-8")
(BAD / "app.py").write_text('OPENAI_API_KEY = "sk-proj-' + "0" * 24 + '" # 지우는 것을 잊은 줄\n' + app, encoding="utf-8")
# 실수 2: requirements.txt 에서 pypdf 와 faiss-cpu 를 뺀다(import 에 안 보여 잊기 쉽다)
req = (BAD / "requirements.txt").read_text(encoding="utf-8").splitlines()
(BAD / "requirements.txt").write_text("\n".join(l for l in req if not l.startswith(("pypdf", "faiss-cpu"))) + "\n", encoding="utf-8")
# 실수 3: .gitignore 를 지운다
(BAD / ".gitignore").unlink()
# 실수 4: 견본 대신 진짜 이름의 secrets.toml 을 둔다(값은 자리표시)
(BAD / ".streamlit" / "secrets.toml.example").rename(BAD / ".streamlit" / "secrets.toml")
for folder in [GOOD, BAD]:
print(folder, "→", sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file()))out_ch07_deploy → ['.gitignore', '.streamlit/secrets.toml.example', 'app.py', 'requirements.txt', 'travel_terms.pdf']
out_ch07_deploy_bad → ['.streamlit/secrets.toml', 'app.py', 'requirements.txt', 'travel_terms.pdf']코드가 의미하는 것 shutil.rmtree 로 지난번 폴더를 지우고 shutil.copytree 로 좋은 폴더를 그대로 복사한 뒤, 배포에서 흔한 실수 넷을 일부러 넣었습니다. 키 꼴의 글은 0 으로 채운 가짜 값이라 실제 키가 아닙니다. 점검 스크립트가 실수를 잡는지 보려면 "잡혀야 하는 폴더"가 있어야 하므로 이 단계를 먼저 둡니다.
2단계. 파일 점검 다섯 가지
import ast
import re
REQUIRED_FILES = ["app.py", "requirements.txt", "travel_terms.pdf", ".gitignore"]
# import 이름 → 패키지 이름. import 에 안 보이는 것은 HIDDEN 에 따로 적는다
IMPORT_TO_PACKAGE = {
"streamlit": "streamlit", "dotenv": "python-dotenv",
"langchain_core": "langchain-core", "langchain_community": "langchain-community",
"langchain_classic": "langchain-classic", "langchain_openai": "langchain-openai",
"langchain_text_splitters": "langchain-text-splitters",
}
HIDDEN = {"PyPDFLoader": "pypdf", "FAISS": "faiss-cpu"}
KEY_PATTERN = re.compile(r"sk-[A-Za-z0-9_-]{20,}")
def row(folder, name, ok, why):
return {"폴더": folder.name, "점검": name, "결과": "통과" if ok else "고칠 것", "까닭": why}
def check_files(folder):
missing = [f for f in REQUIRED_FILES if not (folder / f).exists()]
return row(folder, "꼭 있어야 할 파일", not missing, "빠진 파일: " + ", ".join(missing) if missing else "네 개 모두 있음")
def check_requirements(folder):
code = (folder / "app.py").read_text(encoding="utf-8")
# ast.parse: 코드를 실행하지 않고 문법 나무로 읽는다. ast = abstract syntax tree(추상 구문 나무)
tree = ast.parse(code)
mods = {n.module.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and n.module}
mods |= {a.name.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names}
need = {IMPORT_TO_PACKAGE[m] for m in mods if m in IMPORT_TO_PACKAGE}
need |= {pkg for word, pkg in HIDDEN.items() if word in code}
req = (folder / "requirements.txt").read_text(encoding="utf-8")
have = {line.split("==")[0].strip().lower() for line in req.splitlines() if line.strip()}
missing = sorted(need - have)
return row(folder, "패키지 목록", not missing, "빠진 패키지: " + ", ".join(missing) if missing else f"필요한 {len(need)}개 모두 있음")
def check_keys(folder):
found = []
for f in folder.rglob("*"):
if f.is_file() and f.suffix in {".py", ".toml", ".txt", ".example", ""}:
if KEY_PATTERN.search(f.read_text(encoding="utf-8", errors="ignore")):
found.append(f.relative_to(folder).as_posix()) # 키는 찍지 않고 파일 이름만 모은다
return row(folder, "코드에 적힌 키", not found, "키 꼴의 글이 든 파일: " + ", ".join(found) if found else "키 꼴의 글 없음")
def check_gitignore(folder):
path = folder / ".gitignore"
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
missing = [x for x in [".env", ".streamlit/secrets.toml"] if x not in lines]
return row(folder, ".gitignore", not missing, "막지 않은 것: " + ", ".join(missing) if missing else ".env 와 secrets.toml 을 막음")
def check_secrets(folder):
real = (folder / ".streamlit" / "secrets.toml").exists()
example = (folder / ".streamlit" / "secrets.toml.example").exists()
ok = example and not real
why = "견본만 있음" if ok else ("진짜 이름의 secrets.toml 이 있음" if real else "견본이 없음")
return row(folder, "비밀 값 견본", ok, why)
FILE_CHECKS = [check_files, check_requirements, check_keys, check_gitignore, check_secrets]
for check in FILE_CHECKS:
r = check(GOOD)
print(f"{r['점검']:10} {r['결과']:5} {r['까닭']}")꼭 있어야 할 파일 통과 네 개 모두 있음
패키지 목록 통과 필요한 9개 모두 있음
코드에 적힌 키 통과 키 꼴의 글 없음
.gitignore 통과 .env 와 secrets.toml 을 막음
비밀 값 견본 통과 견본만 있음코드가 의미하는 것 다섯 함수는 모두 파일을 읽기만 하고 앱을 실행하지 않습니다. check_requirements 는 ast.parse 로 앱 코드의 import 를 모으고 HIDDEN 으로 import 에 안 보이는 패키지를 더해, 클라우드가 설치할 목록과 견줍니다. check_keys 는 키 꼴의 글이 든 파일 이름만 모아 키 자체는 찍지 않고, row() 가 모든 점검을 같은 꼴의 사전으로 돌려줘 나중에 한 표로 모을 수 있게 합니다.
3단계. AppTest 점검 두 가지
import streamlit as st
from streamlit.testing.v1 import AppTest
def check_app(folder):
"""첫 화면과 질문 하나를 연습 모드로 시험한다"""
st.cache_resource.clear()
at = AppTest.from_file(str(folder / "app.py"), default_timeout=30)
# 자리표시를 Secrets 에 넣어 연습 모드로 돌린다(진짜 모델을 부르지 않는다)
at.secrets["OPENAI_API_KEY"] = "발급받은_인증키"
at.run()
first_ok = len(at.exception) == 0 and len(at.title) == 1
rows = [row(folder, "첫 화면", first_ok, f"제목 {len(at.title)}개, 오류 {len(at.exception)}개")]
at.chat_input[0].set_value("여권은 기간이 얼마나 남아야 해요?").run()
bot = at.chat_message[-1]
sources = [c.value for c in bot.expander[0].caption] if bot.expander else []
answer_ok = len(at.exception) == 0 and bot.name == "assistant" and any("조" in s for s in sources)
rows.append(row(folder, "질문 하나", answer_ok, "근거: " + " / ".join(sources)))
return rows
for r in check_app(GOOD):
print(f"{r['점검']:6} {r['결과']:5} {r['까닭']}")첫 화면 통과 제목 1개, 오류 0개
질문 하나 통과 근거: 4쪽 · 제13조, 제14조 / 1쪽 · 제1조, 제2조코드가 의미하는 것 check_app 은 캐시를 비우고 Secrets 에 자리표시를 넣어 연습 모드로 앱을 돌립니다. 첫 화면은 오류 수와 제목 수로, 질문 하나는 답 말풍선의 역할과 접힌 칸의 근거에 "조"가 들었는지로 가립니다. 진짜 모델을 부르지 않으므로 키 없이, 몇 초 만에 같은 결과를 되풀이해 얻습니다.
4단계. 두 폴더를 점검해 표로 모으기
import pandas as pd
rows = []
for folder in [GOOD, BAD]:
rows += [check(folder) for check in FILE_CHECKS]
rows += check_app(folder)
table = pd.DataFrame(rows)
print(table.pivot(index="점검", columns="폴더", values="결과").loc[[r["점검"] for r in rows[:7]]].to_string())
print()
for _, r in table[table["결과"] == "고칠 것"].iterrows():
print(f"[{r['폴더']}] {r['점검']}: {r['까닭']}")
table.to_csv("out_ch07_deploy_check.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch07_deploy_check.csv,", len(table), "행")폴더 out_ch07_deploy out_ch07_deploy_bad
점검
꼭 있어야 할 파일 통과 고칠 것
패키지 목록 통과 고칠 것
코드에 적힌 키 통과 고칠 것
.gitignore 통과 고칠 것
비밀 값 견본 통과 고칠 것
첫 화면 통과 통과
질문 하나 통과 통과
[out_ch07_deploy_bad] 꼭 있어야 할 파일: 빠진 파일: .gitignore
[out_ch07_deploy_bad] 패키지 목록: 빠진 패키지: faiss-cpu, pypdf
[out_ch07_deploy_bad] 코드에 적힌 키: 키 꼴의 글이 든 파일: app.py
[out_ch07_deploy_bad] .gitignore: 막지 않은 것: .env, .streamlit/secrets.toml
[out_ch07_deploy_bad] 비밀 값 견본: 진짜 이름의 secrets.toml 이 있음
저장: out_ch07_deploy_check.csv, 14 행실습 6의 out_ch07_deploy 는 일곱 점검을 모두 통과했습니다. 패키지 점검은 앱이 import 하는 일곱 개에 pypdf 와 faiss-cpu 를 더해 필요한 패키지 9개가 모두 있다고 확인했고, 질문 하나 점검에서는 "여권은 기간이 얼마나 남아야 해요?"의 첫 근거로 제14조가 든 4쪽 조각이 나왔습니다. 실수를 넣은 out_ch07_deploy_bad 는 파일 점검 다섯 가지에서 모두 "고칠 것"이 나왔습니다. .gitignore 하나를 지운 실수가 "꼭 있어야 할 파일"과 ".gitignore" 두 점검에 함께 걸렸고, 빠진 패키지 둘(faiss-cpu, pypdf), 키 꼴의 글이 든 app.py, 진짜 이름의 secrets.toml 도 모두 잡혔습니다. 그런데 이 폴더도 AppTest 의 첫 화면과 질문 하나는 통과했습니다. 내 PC 에는 빠진 두 패키지가 설치되어 있어 앱이 돌았기 때문입니다.
그래서 배포할 수 있는 것은 out_ch07_deploy 하나입니다. out_ch07_deploy_bad 를 그대로 올리면 클라우드에서는 pypdf 와 faiss-cpu 가 없어 앱이 뜨지 않고, .gitignore 가 없어 secrets.toml 이 저장소에 올라갑니다. 키를 적은 줄을 지우고, 두 패키지를 requirements.txt 에 더하고, .gitignore 를 다시 만들고, secrets.toml 은 견본으로 바꿔야 합니다. 화면 시험만으로는 이 네 가지를 잡지 못하므로 파일 점검을 함께 돌립니다.
코드가 의미하는 것 두 폴더에 일곱 점검을 모두 돌려 pd.DataFrame 한 표로 모으고, pivot 으로 점검을 줄에, 폴더를 칸에 놓아 견주기 쉽게 했습니다. "고칠 것"인 줄만 골라 까닭을 찍고, 표 전체는 out_ch07_deploy_check.csv 로 남깁니다. git push 하기 전마다 이 스크립트를 돌리면 화면 시험만으로는 못 잡는 파일 문제를 같은 기준으로 확인합니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 가게로 보낼 상자를 싣기 전에 점검표를 들고 상자를 열어 보는 일과 같습니다. 일부러 빠뜨린 상자를 하나 만들고(1단계), 상자 속 물건을 목록과 대조하고(2단계), 상자를 풀어 한 번 차려 보고(3단계), 두 상자의 점검표를 한 장에 모읍니다(4단계). 여기서 점검표가 out_ch07_deploy_check.csv, 상자 속 물건 대조가 파일 점검, 차려 보기가 AppTest 점검입니다. 비유와 다른 점은, 사람의 눈은 하다 보면 빠뜨리지만 이 스크립트는 코드를 고칠 때마다 같은 일곱 가지를 똑같이 본다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 실수 넣기 | 좋은 폴더 파일 5개, 나쁜 폴더 4개 | .gitignore 가 빠지고 견본이 진짜 이름으로 바뀌었습니다 |
| 2. 파일 점검 | 좋은 폴더 다섯 모두 통과 | 필요한 패키지 9개가 모두 있고, 키 꼴의 글이 없습니다 |
| 3. AppTest 점검 | 제목 1개, 오류 0개, 근거 4쪽 · 제13조, 제14조 | 연습 모드로 첫 화면과 질문 하나가 오류 없이 돌았습니다 |
| 4. 표 | 14행, 나쁜 폴더 "고칠 것" 5개 | 나쁜 폴더는 파일 점검만 걸리고 화면 점검은 통과했습니다 |
한 줄로 줄이면, 배포 준비는 "화면이 돈다"와 "올릴 파일이 갖춰졌다"를 따로 확인해야 끝난다는 것을 표로 본 프로젝트입니다. 나쁜 폴더는 내 PC 에서 AppTest 를 두 개 다 통과했지만, 클라우드에서는 빠진 패키지 때문에 뜨지 않고 비밀 값이 새어 나갈 상태였습니다. 점검 스크립트를 배포 폴더 옆에 두고 git push 하기 전마다 돌리면, 사람이 빠뜨리기 쉬운 이 네 가지를 매번 같은 기준으로 확인할 수 있습니다.