LangChain 챗봇7장 · 웹 UI 연동과 배포19 / 22← 이전목차다음 →MetaContents

7장. 웹 UI 연동과 배포

실습 6 — 배포할 폴더 만들기

실습 5의 문서 챗봇을 GitHub 저장소로 올릴 수 있는 폴더(out_ch07_deploy/)로 정리합니다. 앱 파일 app.py, 앱이 읽는 travel_terms.pdf, 패키지 목록 requirements.txt, 비밀 값 견본 .streamlit/secrets.toml.example, 올리지 않을 파일을 적은 .gitignore 를 만들고, AppTest 로 첫 화면이 도는지 봅니다. 배포할 앱은 키가 있으면 ChatOpenAI 로, 키가 없거나 자리표시(발급받은_인증키) 그대로면 가짜 모델의 "연습 모드"로 답합니다. 이 실습은 모델을 부르지 않으므로 API 키가 없어도 됩니다.

1단계. 폴더와 앱 파일 만들기

Python
import shutil
from pathlib import Path

DEPLOY = Path("out_ch07_deploy")
# parents=True: 중간 폴더까지 만든다. exist_ok=True: 이미 있으면 그대로 둔다
(DEPLOY / ".streamlit").mkdir(parents=True, exist_ok=True)

APP_CODE = r'''
import os
import re
import zlib
from pathlib import Path

import streamlit as st
from dotenv import find_dotenv, load_dotenv
from langchain_classic.chains import RetrievalQA
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 앱 파일과 같은 폴더의 약관 PDF. 클라우드에서도 저장소 안의 이 파일을 찾는다
PDF = Path(__file__).parent / "travel_terms.pdf"
HEADER = "구름다리여행 여행 약관 (연습용 가상 문서)"
PLACEHOLDER = "발급받은_인증키"


class BigramEmbeddings(Embeddings):
    """글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""

    def __init__(self, size=512):
        self.size = size

    def _vector(self, text):
        text = text.replace(" ", "").replace("\n", "")
        vec = [0.0] * self.size
        for i in range(len(text) - 1):
            # 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
            vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
        norm = sum(v * v for v in vec) ** 0.5 or 1.0
        return [v / norm for v in vec]

    def embed_documents(self, texts):
        return [self._vector(t) for t in texts]

    def embed_query(self, text):
        return self._vector(text)


def get_api_key():
    """클라우드에서는 Secrets 칸, 내 PC 에서는 .env 에서 키를 읽는다"""
    try:
        return st.secrets["OPENAI_API_KEY"]
    except (FileNotFoundError, KeyError):
        load_dotenv(find_dotenv(usecwd=True))
        return os.getenv("OPENAI_API_KEY")


@st.cache_resource(show_spinner="약관으로 색인을 만드는 중입니다...")
def load_retriever():
    """PDF 읽기 → 머리글 지우기 → 조각 내기 → FAISS 색인. 서버에서 한 번만 한다"""
    pages = PyPDFLoader(str(PDF)).load()
    for page in pages:
        page.page_content = page.page_content.replace(HEADER, "").strip()
    chunks = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=30).split_documents(pages)
    db = FAISS.from_documents(chunks, BigramEmbeddings())
    return db.as_retriever(search_kwargs={"k": 2}), len(chunks)


@st.cache_resource
def load_qa(api_key):
    """키가 있으면 진짜 모델, 없거나 자리표시면 연습 모드의 가짜 모델로 RetrievalQA 를 만든다"""
    retriever, _ = load_retriever()
    if api_key and api_key != PLACEHOLDER:
        llm = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"), api_key=api_key, temperature=0)
        mode = "OpenAI"
    else:
        llm = FakeListChatModel(responses=["(연습 모드) 진짜 모델 없이 약관만 찾았습니다. 아래 근거 조항을 확인해 주세요."])
        mode = "연습 모드"
    qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever, return_source_documents=True)
    return qa, mode


def source_label(doc):
    """근거 조각 하나를 '2쪽 · 제5조, 제6조' 꼴로 줄인다"""
    names = list(dict.fromkeys(re.findall(r"제\d+조", doc.page_content)))
    return f"{doc.metadata['page'] + 1}쪽 · {', '.join(names)}"


def show(message):
    with st.chat_message(message["role"]):
        st.markdown(message["content"])
        if message.get("sources"):
            with st.expander("근거 조항 보기"):
                for s in message["sources"]:
                    st.caption(s)


st.set_page_config(page_title="구름다리여행 약관 챗봇")
st.title("구름다리여행 약관 챗봇")
_, n_chunks = load_retriever()
qa, mode = load_qa(get_api_key())
with st.sidebar:
    st.header("문서")
    st.caption(f"약관 조각: {n_chunks}개")
    st.caption(f"답하는 모델: {mode}")
    st.caption("연습용으로 지어낸 가상의 약관입니다.")

if "doc_messages" not in st.session_state:
    st.session_state.doc_messages = []
for m in st.session_state.doc_messages:
    show(m)

if question := st.chat_input("약관에 대해 물어보세요"):
    user = {"role": "user", "content": question}
    show(user)
    with st.spinner("약관에서 찾는 중입니다..."):
        result = qa.invoke({"query": question})
    bot = {"role": "assistant", "content": result["result"],
           "sources": [source_label(d) for d in result["source_documents"]]}
    show(bot)
    st.session_state.doc_messages.extend([user, bot])
'''

(DEPLOY / "app.py").write_text(APP_CODE.lstrip(), encoding="utf-8")
# 앱이 읽는 PDF 도 같은 폴더에 복사한다. 저장소에 함께 올라가야 클라우드에서 찾는다
shutil.copy("travel_terms.pdf", DEPLOY / "travel_terms.pdf")
print("저장:", DEPLOY / "app.py", "/", len(APP_CODE.strip().splitlines()), "줄")
print("복사:", DEPLOY / "travel_terms.pdf", "/", (DEPLOY / "travel_terms.pdf").stat().st_size, "바이트")
출력
저장: out_ch07_deploy\app.py / 116 줄
복사: out_ch07_deploy\travel_terms.pdf / 58302 바이트

코드가 의미하는 것 배포할 앱 app.py 는 실습 5의 문서 챗봇에 실습 4의 get_api_key() 를 더하고, 색인(load_retriever)과 답하는 체인(load_qa)을 두 캐시 함수로 나눴습니다. load_qa(api_key) 는 키가 있고 자리표시가 아닐 때만 ChatOpenAI 를, 아니면 연습 모드의 가짜 모델을 고르므로 키 없이도 앱이 뜹니다. shutil.copy 로 PDF 를 같은 폴더에 두고 앱은 Path(__file__).parent 로 찾으므로, 클라우드에서도 저장소 안의 파일을 읽습니다.

2단계. requirements.txt 쓰기

Python
from importlib.metadata import version

# 앱이 import 하는 패키지와, import 에는 안 보이지만 안쪽에서 쓰는 패키지(pypdf, faiss-cpu)
PACKAGES = [
    "streamlit", "python-dotenv",
    "langchain-core", "langchain-community", "langchain-classic", "langchain-openai", "langchain-text-splitters",
    "pypdf", "faiss-cpu",
]
# version(이름): 이 PC 에 설치된 판. "이름==판" 으로 적어 클라우드에도 같은 판을 설치하게 한다
lines = [f"{name}=={version(name)}" for name in PACKAGES]
(DEPLOY / "requirements.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print("저장:", DEPLOY / "requirements.txt")
print("\n".join(lines))
출력
저장: out_ch07_deploy\requirements.txt
streamlit==1.66.1
python-dotenv==1.2.4
langchain-core==1.7.3
langchain-community==0.4.2
langchain-classic==1.0.8
langchain-openai==1.7.3
langchain-text-splitters==1.1.3
pypdf==6.21.4
faiss-cpu==1.15.1

코드가 의미하는 것 importlib.metadata 의 version() 으로 이 PC 에 설치된 판을 읽어 이름==판 꼴로 적었습니다. 클라우드 서버는 이 파일만 보고 패키지를 설치하므로, import 에 보이지 않는 pypdf 와 faiss-cpu 까지 적어야 앱이 뜹니다. 판을 고정해 두면 클라우드가 내 PC 와 같은 판을 설치합니다.

3단계. 비밀 값 견본과 .gitignore 쓰기

Python
# 비밀 값 견본: 값은 자리표시. 진짜 키는 클라우드의 Secrets 칸에 넣는다
SECRETS_EXAMPLE = '''# 이 파일을 복사해 이름을 secrets.toml 로 바꾸고 값을 넣는다. secrets.toml 은 올리지 않는다
OPENAI_API_KEY = "발급받은_인증키"
OPENAI_MODEL = "gpt-4.1-mini"
'''
(DEPLOY / ".streamlit" / "secrets.toml.example").write_text(SECRETS_EXAMPLE, encoding="utf-8")

# .gitignore: git 이 올리지 않을 파일. 키가 든 두 파일과 파이썬이 만드는 찌꺼기 폴더
GITIGNORE = """.env
.streamlit/secrets.toml
__pycache__/
.venv/
"""
(DEPLOY / ".gitignore").write_text(GITIGNORE, encoding="utf-8")

# 폴더 안의 파일을 모두 찍어 본다. rglob("*"): 아래 폴더까지 모두 찾는다
for f in sorted(p for p in DEPLOY.rglob("*") if p.is_file()):
    print(f.relative_to(DEPLOY).as_posix(), f.stat().st_size, "바이트")
출력
.gitignore 53 바이트
.streamlit/secrets.toml.example 193 바이트
app.py 4738 바이트
requirements.txt 210 바이트
travel_terms.pdf 58302 바이트

코드가 의미하는 것 secrets.toml.example 은 열쇠를 두는 자리의 안내문이고, 값이 자리표시라 저장소에 올려도 됩니다. .gitignore 는 git 이 올리지 않을 파일 목록으로, 키가 든 .env 와 .streamlit/secrets.toml 을 막습니다. rglob("*") 로 폴더를 훑어 저장소에 올라갈 파일을 눈으로 확인합니다.

4단계. 배포할 앱의 첫 화면 시험하기

Python
import streamlit as st
from streamlit.testing.v1 import AppTest

st.cache_resource.clear()

at = AppTest.from_file("out_ch07_deploy/app.py", default_timeout=30)
# 자리표시를 Secrets 에 넣어 연습 모드로 시험한다(진짜 모델을 부르지 않으려고)
at.secrets["OPENAI_API_KEY"] = "발급받은_인증키"
at.run()
print("제목:", at.title[0].value)
print("사이드바:", [c.value for c in at.sidebar.caption])
print("오류:", len(at.exception), "개")
출력
제목: 구름다리여행 약관 챗봇
사이드바: ['약관 조각: 11개', '답하는 모델: 연습 모드', '연습용으로 지어낸 가상의 약관입니다.']
오류: 0 개

코드가 의미하는 것 배포 폴더 안의 app.py 를 AppTest.from_file 로 열어, 클라우드에 올리기 전에 내 PC 에서 한 번 차려 봅니다. Secrets 에 자리표시를 넣었으므로 load_qa 는 연습 모드를 고르고 진짜 모델은 불리지 않습니다. 사이드바의 "답하는 모델" 줄이 어느 쪽을 골랐는지 알려 줍니다.

이 실습이 뜻하는 것

이 실습은 집 부엌에서 맛을 본 메뉴를 가게로 옮기려고, 조리법과 재료 상자, 납품 업체에 넘길 재료 목록, 열쇠를 두는 자리 안내문, "가게 밖으로 들고 나가지 않을 것" 목록을 한 상자에 꾸리는 일과 같습니다. 조리법과 재료를 담고(1단계), 재료 목록을 쓰고(2단계), 안내문과 금지 목록을 넣고(3단계), 상자를 풀어 한 번 차려 봅니다(4단계). 여기서 조리법이 app.py, 재료가 travel_terms.pdf, 재료 목록이 requirements.txt, 금지 목록이 .gitignore 입니다. 비유와 다른 점은, 클라우드 서버는 이 상자에 든 것만 알고 내 PC 에 있던 것은 하나도 모른다는 것입니다.

단계출력뜻
1. 앱과 PDFapp.py 116 줄, PDF 58,302 바이트앱과 앱이 읽는 파일이 한 폴더에 모였습니다
2. 패키지 목록아홉 줄, 예: streamlit==1.66.1import 하는 일곱 개와 안쪽에서 쓰는 pypdf, faiss-cpu 를 이 PC 의 판으로 적었습니다
3. 견본과 .gitignore파일 다섯 개.gitignore, .streamlit/secrets.toml.example, app.py, requirements.txt, travel_terms.pdf 가 저장소에 올라갈 전부입니다
4. 첫 화면조각 11개, 연습 모드, 오류 0개자리표시 키라 가짜 모델을 골랐고, 앱이 오류 없이 떴습니다

한 줄로 줄이면, 배포할 폴더는 앱 파일 하나가 아니라 "앱, 앱이 읽는 파일, 패키지 목록, 비밀 값 견본, 올리지 않을 목록"의 다섯 가지 묶음이라는 것을 만든 실습입니다. requirements.txt 의 판은 이 교안을 만든 PC(Python 3.14.6)에 설치된 판이라, 여러분의 PC 에서 돌리면 여러분이 설치한 판이 적힙니다. 사이드바의 "답하는 모델: 연습 모드" 는 Secrets 에 자리표시를 넣었기 때문이고, Community Cloud 의 Secrets 칸에 진짜 키를 넣으면 "OpenAI" 로 바뀝니다. 이 폴더가 정말 배포할 수 있는 상태인지는 프로젝트에서 점검표로 확인합니다.

코드 설명

  • load_qa(api_key) 는 키가 있고 자리표시가 아닐 때만 ChatOpenAI 를 만들고, 아니면 연습 모드의 가짜 모델을 씁니다. 키가 없어도 앱이 멈추지 않고 검색만은 해 보입니다.
  • shutil.copy("travel_terms.pdf", DEPLOY / "travel_terms.pdf") 는 앱이 읽는 PDF 를 배포 폴더로 복사합니다. shutil 은 shell utilities(명령 창에서 하던 파일 일)의 줄임말입니다.
  • version(name) 은 importlib.metadata 의 함수로, 설치된 패키지의 판을 돌려줍니다. == 로 판을 고정하면 클라우드가 내 PC 와 같은 판을 설치합니다. 판을 적지 않으면 그날의 최신 판이 설치되어 내 PC 와 다르게 돌 수 있습니다.
  • .streamlit/secrets.toml.example 은 이름과 꼴만 보여 주는 견본입니다. 진짜 값이 든 secrets.toml 은 .gitignore 에 적어 올리지 않습니다.
  • DEPLOY.rglob("*") 는 폴더 안을 아래 폴더까지 모두 훑습니다. .gitignore 처럼 점으로 시작하는 파일과 .streamlit 안의 파일도 찾습니다.
← 이전 절7장 · 실습 5 — 문서 챗봇 웹 화면다음 절 →7장 · 프로젝트 1 — 배포 점검