🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Streamlit으로 RAG 챗봇 UI 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 18. · 7 Views

Streamlit으로 RAG 챗봇 UI 완벽 가이드

RAG 챗봇을 위한 Streamlit UI 구축 방법을 단계별로 배웁니다. AWS Bedrock Knowledge Base를 활용한 대화형 인터페이스 구현부터 스트리밍 응답 표시까지, 실무에 바로 적용 가능한 내용을 다룹니다.


목차

  1. Streamlit_소개
  2. 챗봇_UI_구조_설계
  3. 대화_인터페이스_구현
  4. Knowledge_Base_연동
  5. 출처_표시_UI
  6. 스트리밍_응답_표시

1. Streamlit 소개

김개발 씨는 회사에서 RAG 챗봇 프로젝트를 맡게 되었습니다. 백엔드는 완성했지만 UI를 어떻게 만들어야 할지 막막했습니다.

"프론트엔드 전문가도 아닌데, 빠르게 만들 방법이 없을까요?"

Streamlit은 Python 코드만으로 웹 UI를 만들 수 있는 프레임워크입니다. 마치 파워포인트로 슬라이드를 만들듯이 간단한 코드로 화면을 구성할 수 있습니다.

특히 데이터 분석이나 AI 프로젝트에서 빠르게 프로토타입을 만들 때 매우 유용합니다.

다음 코드를 살펴봅시다.

import streamlit as st

# 페이지 설정 - 타이틀과 아이콘 지정
st.set_page_config(
    page_title="RAG 챗봇",
    page_icon="🤖"
)

# 제목 표시
st.title("RAG 챗봇 데모")

# 입력 받기
user_input = st.text_input("질문을 입력하세요:")

# 버튼 클릭 시 동작
if st.button("전송"):
    st.write(f"입력하신 질문: {user_input}")

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 최근 회사에서 RAG 기반 지식 검색 챗봇을 만드는 프로젝트를 맡게 되었습니다.

AWS Bedrock으로 백엔드는 완성했지만, 사용자가 실제로 사용할 UI가 필요했습니다. "프론트엔드는 React나 Vue를 써야 하는데, 저는 잘 모르는데요?" 고민하던 김개발 씨에게 선배 박시니어 씨가 다가왔습니다.

"Streamlit 써봤어? Python만 알면 UI 금방 만들 수 있어." Streamlit이란 무엇일까요? 쉽게 비유하자면, Streamlit은 마치 파워포인트와 같습니다.

파워포인트에서 텍스트 박스를 추가하고, 이미지를 넣고, 버튼을 만들듯이 Streamlit도 Python 함수 호출만으로 UI 요소를 화면에 추가할 수 있습니다. st.title()을 쓰면 제목이 나타나고, st.button()을 쓰면 버튼이 생깁니다.

왜 Streamlit이 필요한가? 전통적인 웹 개발 방식에서는 HTML로 구조를 만들고, CSS로 스타일을 입히고, JavaScript로 동작을 구현해야 했습니다. 간단한 입력 폼 하나를 만드는 데도 세 가지 언어를 오가며 작업해야 했죠.

더 큰 문제는 프론트엔드와 백엔드를 연결하는 작업이었습니다. RESTful API를 설계하고, CORS 설정을 하고, 비동기 통신을 구현하는 등 부수적인 작업이 너무 많았습니다.

Python으로 멋진 AI 모델을 만들어도, 그것을 웹에서 보여주려면 완전히 다른 기술 스택을 배워야 했습니다. Streamlit의 등장 바로 이런 문제를 해결하기 위해 Streamlit이 등장했습니다.

Streamlit을 사용하면 Python 코드만으로 완전한 웹 애플리케이션을 만들 수 있습니다. 별도의 HTML, CSS, JavaScript 파일이 필요 없습니다.

또한 프론트엔드와 백엔드가 하나의 Python 파일 안에 있어서 코드 관리가 훨씬 간단합니다. 무엇보다 코드를 수정하면 자동으로 화면이 새로고침되어 개발 속도가 매우 빠릅니다.

코드 살펴보기 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 st.set_page_config()로 브라우저 탭에 표시될 제목과 아이콘을 설정합니다.

이 부분은 HTML의 <title> 태그와 파비콘을 설정하는 것과 같습니다. 다음으로 st.title()은 화면에 큰 제목을 표시합니다.

st.text_input()은 사용자로부터 텍스트를 입력받는 입력창을 만듭니다. 사용자가 입력한 값은 자동으로 user_input 변수에 저장됩니다.

st.button()은 클릭 가능한 버튼을 생성하고, 버튼이 클릭되면 True를 반환합니다. 마지막으로 st.write()는 화면에 텍스트를 출력합니다.

실행 방법 코드를 app.py라는 파일로 저장한 후, 터미널에서 streamlit run app.py 명령을 실행하면 됩니다. 그러면 자동으로 웹 브라우저가 열리면서 여러분의 앱이 실행됩니다.

기본적으로 http://localhost:8501 주소로 접속됩니다. 실무 활용 사례 실제 현업에서는 어떻게 활용할까요?

스타트업 A사는 내부 직원들이 사용할 데이터 분석 대시보드를 만들어야 했습니다. 전담 프론트엔드 개발자를 고용할 여유가 없었지만, Streamlit 덕분에 데이터 과학자 한 명이 일주일 만에 완성했습니다.

대기업 B사는 고객 상담용 AI 챗봇 프로토타입을 Streamlit으로 먼저 만들어 내부 검증을 거친 후, 본격적인 프론트엔드 개발에 착수했습니다. 주의사항 하지만 주의할 점도 있습니다.

Streamlit은 빠른 프로토타이핑과 내부 도구 개발에는 완벽하지만, 대규모 서비스용으로는 적합하지 않을 수 있습니다. 페이지가 새로고침될 때마다 전체 코드가 다시 실행되기 때문에, 복잡한 로직이 있다면 성능 최적화가 필요합니다.

또한 디자인 커스터마이징에 한계가 있어서, 세밀한 UI/UX를 원한다면 전통적인 프론트엔드 프레임워크를 고려해야 합니다. 정리 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언을 듣고 Streamlit을 설치한 김개발 씨는 30분 만에 첫 화면을 만들었습니다. "와, 정말 쉽네요!" Streamlit을 제대로 활용하면 Python 개발자도 멋진 웹 UI를 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - st.set_page_config()는 반드시 스크립트 맨 위에 작성해야 합니다

  • 개발 중에는 자동 새로고침 기능이 매우 유용하므로 켜두세요
  • st.cache_data 데코레이터로 무거운 연산을 캐싱하면 성능이 크게 향상됩니다

2. 챗봇 UI 구조 설계

Streamlit 기본 사용법을 익힌 김개발 씨는 본격적으로 챗봇 UI를 만들기 시작했습니다. 그런데 대화 내역을 어떻게 저장하고 표시해야 할지 막막했습니다.

"채팅 앱처럼 대화가 계속 쌓여야 하는데, 페이지가 새로고침되면 다 사라지잖아요?"

챗봇 UI는 세션 상태를 활용해 대화 내역을 관리합니다. Streamlit의 st.session_state는 마치 웹사이트의 쿠키처럼 사용자별 데이터를 저장하는 공간입니다.

이를 활용하면 페이지가 새로고침되어도 대화 내역이 유지됩니다.

다음 코드를 살펴봅시다.

import streamlit as st

# 세션 상태 초기화 - 최초 1회만 실행됨
if "messages" not in st.session_state:
    st.session_state.messages = []

# 채팅 UI 제목
st.title("RAG 챗봇")

# 이전 대화 내역 표시
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# 사용자 입력 받기
if prompt := st.chat_input("질문을 입력하세요"):
    # 사용자 메시지 저장 및 표시
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

김개발 씨가 만든 첫 번째 버전은 문제가 있었습니다. 사용자가 질문을 입력하고 답변을 받으면, 다음 질문을 입력할 때 이전 대화가 모두 사라졌습니다.

일반적인 채팅 앱과는 너무 다른 경험이었죠. "왜 이런 일이 생기나요?" 김개발 씨가 박시니어 씨에게 물었습니다.

"Streamlit은 사용자가 무언가 입력할 때마다 Python 스크립트를 처음부터 끝까지 다시 실행하거든. 그래서 변수들이 초기화되는 거야." 세션 상태란 무엇인가? 쉽게 비유하자면, 세션 상태는 마치 노트와 같습니다.

여러분이 계산을 할 때 중간 결과를 노트에 적어두듯이, Streamlit도 기억해야 할 데이터를 세션 상태라는 노트에 적어둡니다. 페이지가 새로고침되어도 이 노트는 그대로 유지됩니다.

일반적인 Python 변수는 스크립트가 다시 실행되면 초기화됩니다. 하지만 st.session_state에 저장한 데이터는 사용자가 브라우저를 닫을 때까지 계속 유지됩니다.

왜 세션 상태가 필요한가? 채팅 애플리케이션의 핵심은 대화의 연속성입니다. 사용자가 "오늘 날씨 어때?"라고 물었다면, 다음 질문으로 "내일은?"이라고만 해도 맥락을 이해해야 합니다.

이전 대화 내역이 없다면 불가능한 일입니다. 또한 사용자 경험 측면에서도 중요합니다.

질문할 때마다 이전 대화가 사라진다면, 사용자는 자신이 무엇을 물었는지 기억하기 어렵습니다. 전문적인 챗봇 서비스처럼 보이려면 대화 내역이 화면에 계속 표시되어야 합니다.

Streamlit의 채팅 UI 구성 요소 Streamlit은 채팅 UI를 위한 전용 컴포넌트를 제공합니다. st.chat_message()는 채팅 메시지를 표시하는 컨테이너입니다.

role 매개변수로 "user""assistant"를 지정하면 자동으로 적절한 아이콘과 스타일이 적용됩니다. st.chat_input()은 화면 하단에 고정되는 입력창을 만들어줍니다.

일반 st.text_input()과 달리 채팅 앱처럼 하단에 붙어 있어서 사용성이 훨씬 좋습니다. 코드 동작 원리 위의 코드를 단계별로 살펴보겠습니다.

먼저 if "messages" not in st.session_state: 부분은 세션 상태에 messages 키가 없을 때만 실행됩니다. 즉, 사용자가 처음 페이지에 접속했을 때 한 번만 실행되어 빈 리스트를 만듭니다.

이후 페이지가 새로고침되어도 이미 messages가 존재하므로 이 부분은 건너뜁니다. 다음으로 for 루프에서 저장된 모든 메시지를 순서대로 화면에 표시합니다.

각 메시지는 {"role": "user", "content": "안녕하세요"} 같은 딕셔너리 형태로 저장되어 있습니다. := 연산자는 Python 3.8에서 도입된 왈러스 연산자입니다.

값을 할당하면서 동시에 조건을 확인할 수 있어서 코드가 간결해집니다. 사용자가 입력창에 텍스트를 입력하고 엔터를 누르면 prompt 변수에 저장되면서 if 블록이 실행됩니다.

메시지 저장 구조 대화 내역은 리스트 형태로 저장됩니다. 각 메시지는 rolecontent라는 두 개의 키를 가진 딕셔너리입니다.

이 구조는 OpenAI API의 메시지 형식과 동일해서, 나중에 AI 모델과 연동할 때 매우 편리합니다. 예를 들어 사용자가 "안녕하세요"라고 입력하면 [{"role": "user", "content": "안녕하세요"}]가 저장됩니다.

AI가 "안녕하세요! 무엇을 도와드릴까요?"라고 답하면 `[{"role": "user", "content": "안녕하세요"}, {"role": "assistant", "content": "안녕하세요!

무엇을 도와드릴까요?"}]`가 됩니다. 실무 활용 실제 프로젝트에서는 세션 상태로 더 많은 정보를 관리합니다.

예를 들어 사용자의 선택 옵션, 업로드한 파일, 인증 상태 등을 모두 세션 상태에 저장할 수 있습니다. 한 스타트업은 다단계 설문조사 앱을 만들 때 각 단계의 답변을 세션 상태에 저장해서, 사용자가 이전 단계로 돌아가도 입력했던 내용이 유지되도록 구현했습니다.

주의사항 세션 상태는 서버 메모리에 저장됩니다. 따라서 너무 많은 데이터를 저장하면 메모리 부족 문제가 발생할 수 있습니다.

특히 파일이나 대용량 데이터프레임을 저장할 때는 주의해야 합니다. 또한 세션 상태는 사용자별로 독립적이므로, 동시 접속자가 많아지면 서버 리소스가 빠르게 소진될 수 있습니다.

정리 김개발 씨는 세션 상태를 활용해 대화 내역이 유지되는 챗봇을 완성했습니다. "이제 진짜 채팅 앱처럼 보이네요!" 세션 상태를 제대로 이해하면 Streamlit으로 훨씬 더 복잡한 상호작용을 구현할 수 있습니다.

여러분도 자신만의 챗봇 UI를 만들어 보세요.

실전 팁

💡 - 세션 상태 초기화는 항상 코드 초반부에 배치하세요

  • 복잡한 객체보다는 간단한 데이터 구조(리스트, 딕셔너리)를 저장하는 것이 안전합니다
  • st.session_state.clear() 메서드로 모든 세션 데이터를 초기화할 수 있습니다

3. 대화 인터페이스 구현

대화 내역 저장 문제를 해결한 김개발 씨는 이제 실제 AI 응답을 연결해야 했습니다. AWS Bedrock Knowledge Base API를 호출해서 답변을 받아오는 것까지는 성공했는데, 응답 시간이 너무 오래 걸렸습니다.

"사용자가 5초 동안 빈 화면만 보고 있으면 답답해하지 않을까요?"

대화 인터페이스는 사용자 입력을 받아 AI에게 전달하고, 응답을 화면에 표시하는 과정입니다. 로딩 상태 표시는 사용자 경험에서 매우 중요한 요소입니다.

Streamlit의 st.spinner()를 사용하면 처리 중임을 시각적으로 알려줄 수 있습니다.

다음 코드를 살펴봅시다.

import streamlit as st
import boto3

# Bedrock Agent Runtime 클라이언트 생성
bedrock_agent = boto3.client('bedrock-agent-runtime', region_name='us-east-1')

# 사용자 입력 처리
if prompt := st.chat_input("질문을 입력하세요"):
    # 사용자 메시지 표시
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # AI 응답 생성 - 로딩 표시와 함께
    with st.chat_message("assistant"):
        with st.spinner("답변 생성 중..."):
            # Knowledge Base 검색 및 응답 생성
            response = bedrock_agent.retrieve_and_generate(
                input={'text': prompt},
                retrieveAndGenerateConfiguration={
                    'type': 'KNOWLEDGE_BASE',
                    'knowledgeBaseConfiguration': {
                        'knowledgeBaseId': 'YOUR_KB_ID',
                        'modelArn': 'arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2'
                    }
                }
            )
            # 응답 텍스트 추출
            answer = response['output']['text']
            st.markdown(answer)

    # 응답 저장
    st.session_state.messages.append({"role": "assistant", "content": answer})

김개발 씨는 첫 번째 테스트에서 문제를 발견했습니다. 질문을 입력하고 엔터를 누르면 화면이 멈춘 것처럼 보였습니다.

사용자 입장에서는 앱이 죽은 건지, 처리 중인 건지 알 수 없었습니다. "이거 큰 문제인데요?" 김개발 씨가 박시니어 씨에게 화면을 보여줬습니다.

"응답이 오는 데 5초 정도 걸리는데, 그동안 아무 표시도 없어요." 박시니어 씨가 고개를 끄덕였습니다. "로딩 인디케이터를 추가해야겠네.

사용자는 기다릴 수 있지만, 기다리고 있다는 걸 알려줘야 해." 로딩 상태 표시의 중요성 쉽게 비유하자면, 로딩 인디케이터는 마치 식당의 주문 확인과 같습니다. 주문한 음식이 나오려면 시간이 걸리지만, 종업원이 "주문 들어갔습니다.

10분 정도 걸립니다"라고 말해주면 안심하고 기다릴 수 있습니다. 하지만 아무 말 없이 사라지면 "내 주문이 들어간 게 맞나?"하고 불안해집니다.

웹 애플리케이션도 마찬가지입니다. 처리 시간이 1초 이상 걸린다면 반드시 로딩 상태를 표시해야 합니다.

연구에 따르면 사용자는 명확한 피드백이 있을 때 훨씬 더 오래 기다릴 수 있다고 합니다. Streamlit의 spinner 컴포넌트 Streamlit은 로딩 상태를 표시하는 여러 방법을 제공합니다.

st.spinner()는 회전하는 아이콘과 함께 메시지를 표시합니다. with 문과 함께 사용하면 블록 안의 코드가 실행되는 동안 자동으로 스피너가 표시되고, 완료되면 사라집니다.

매우 직관적이고 사용하기 쉽습니다. 다른 옵션으로는 st.progress()가 있습니다.

진행률을 백분율로 표시할 수 있어서 파일 업로드나 배치 처리 같은 작업에 적합합니다. st.status()는 여러 단계를 거치는 작업에서 각 단계의 상태를 표시할 때 유용합니다.

AWS Bedrock Knowledge Base 연동 위 코드의 핵심은 retrieve_and_generate API 호출입니다. 이 API는 사용자의 질문을 받아서 Knowledge Base에서 관련 문서를 검색하고, 그 내용을 바탕으로 LLM이 답변을 생성합니다.

일반적인 검색 시스템과 달리, 단순히 문서를 반환하는 것이 아니라 자연스러운 문장으로 답변을 만들어냅니다. knowledgeBaseId는 AWS 콘솔에서 생성한 Knowledge Base의 고유 ID입니다.

modelArn은 사용할 언어 모델을 지정합니다. Claude, Titan, Llama 등 다양한 모델을 선택할 수 있습니다.

코드 흐름 이해하기 전체 흐름을 단계별로 살펴보겠습니다. 사용자가 질문을 입력하면 먼저 그 내용이 세션 상태에 저장되고 화면에 표시됩니다.

이때 with st.chat_message("user"):를 사용해 사용자 메시지임을 명확히 표시합니다. 다음으로 with st.chat_message("assistant"):로 AI 응답 영역을 만듭니다.

이 블록 안에서 with st.spinner()를 중첩해서 사용합니다. 스피너가 표시되는 동안 Bedrock API를 호출하고, 응답을 받으면 스피너가 사라지면서 답변이 나타납니다.

응답 객체는 중첩된 딕셔너리 구조입니다. 실제 텍스트는 response['output']['text'] 경로에 있습니다.

이 값을 추출해서 화면에 표시하고, 세션 상태에도 저장합니다. 에러 처리 실무에서는 항상 에러 상황을 고려해야 합니다.

네트워크 문제로 API 호출이 실패할 수도 있고, Knowledge Base에 관련 정보가 없을 수도 있습니다. AWS 서비스 자체에 장애가 발생할 수도 있습니다.

이런 경우를 대비해 try-except 블록으로 감싸고, 적절한 에러 메시지를 표시해야 합니다. 예를 들어 `st.error("죄송합니다.

일시적인 오류가 발생했습니다. 다시 시도해주세요.")`처럼 사용자 친화적인 메시지를 보여주는 것이 좋습니다.

실무 팁 실제 서비스에서는 타임아웃 설정이 중요합니다. Boto3 클라이언트를 생성할 때 config=Config(read_timeout=30) 같은 옵션을 추가해서 너무 오래 걸리는 요청은 자동으로 취소되도록 합니다.

또한 응답 캐싱을 구현하면 동일한 질문에 대해 매번 API를 호출하지 않아도 됩니다. 성능 최적화 응답 시간을 줄이는 방법도 있습니다.

Knowledge Base 설정에서 검색할 문서 개수를 줄이면 속도가 빨라집니다. 하지만 답변 품질이 떨어질 수 있으므로 균형을 잡아야 합니다.

또한 더 가벼운 모델을 사용하거나, 프롬프트를 최적화해서 생성할 토큰 수를 줄이는 방법도 효과적입니다. 정리 김개발 씨는 로딩 스피너를 추가한 후 팀원들에게 다시 테스트를 받았습니다.

"훨씬 나아졌어요. 이제 기다리는 동안 답답하지 않아요." 사용자 경험은 작은 디테일에서 결정됩니다.

로딩 인디케이터 하나가 전체 서비스의 만족도를 크게 바꿀 수 있습니다.

실전 팁

💡 - API 호출은 항상 try-except로 감싸서 에러를 처리하세요

  • 환경 변수로 Knowledge Base ID와 리전을 관리하면 배포가 편리합니다
  • 개발 중에는 mock 응답을 사용해서 API 호출 없이 테스트할 수 있습니다

4. Knowledge Base 연동

기본적인 질의응답은 작동했지만, 김개발 씨는 한 가지 문제를 발견했습니다. AI가 답변할 때 어떤 문서를 참고했는지 알 수 없었습니다.

"사용자가 답변의 출처를 확인하고 싶어 하면 어떻게 하죠?"

Knowledge Base는 답변과 함께 참조 문서 정보를 제공합니다. 이 정보에는 원본 문서의 위치, 관련 구절, 유사도 점수 등이 포함됩니다.

출처를 명확히 표시하면 신뢰도가 높아지고, 사용자가 더 자세한 정보를 찾을 수 있습니다.

다음 코드를 살펴봅시다.

import streamlit as st

# Knowledge Base 응답 처리 함수
def get_kb_response(prompt):
    response = bedrock_agent.retrieve_and_generate(
        input={'text': prompt},
        retrieveAndGenerateConfiguration={
            'type': 'KNOWLEDGE_BASE',
            'knowledgeBaseConfiguration': {
                'knowledgeBaseId': st.secrets["KB_ID"],
                'modelArn': st.secrets["MODEL_ARN"]
            }
        }
    )

    # 답변 텍스트 추출
    answer = response['output']['text']

    # 참조 문서 정보 추출
    citations = []
    if 'citations' in response:
        for citation in response['citations']:
            for reference in citation.get('retrievedReferences', []):
                citations.append({
                    'content': reference['content']['text'],
                    'location': reference['location']['s3Location']['uri'],
                    'score': reference.get('score', 0)
                })

    return answer, citations

# 응답 받아오기
answer, citations = get_kb_response(prompt)
st.markdown(answer)

# 참조 문서가 있으면 표시
if citations:
    st.markdown("---")
    st.markdown("**참조 문서:**")
    for i, cite in enumerate(citations, 1):
        st.markdown(f"{i}. 유사도: {cite['score']:.2%}")
        st.caption(f"출처: {cite['location']}")

김개발 씨의 팀에서 베타 테스트를 진행했습니다. 한 동료가 법률 관련 질문을 했는데, AI가 답변은 잘했지만 "이게 어느 문서에서 나온 내용인지 확인하고 싶어요"라고 말했습니다.

실제 서비스에서는 답변의 근거를 보여주는 것이 매우 중요했습니다. "이거 중요한 피드백이네." 박시니어 씨가 말했습니다.

"특히 전문 분야에서는 출처 표시가 필수야. 의료, 법률, 금융 같은 도메인에서는 더욱 그렇지." 참조 문서란 무엇인가? 쉽게 비유하자면, 참조 문서는 마치 논문의 각주와 같습니다.

논문에서 어떤 주장을 할 때 "출처: Smith et al. (2020)"처럼 근거를 명시하듯이, RAG 챗봇도 답변의 근거가 된 문서를 알려줍니다.

일반적인 LLM은 학습된 지식을 바탕으로 답변하지만, 그 지식이 어디서 왔는지 추적하기 어렵습니다. 하지만 RAG 시스템은 명확히 "이 문서의 이 부분을 참고했습니다"라고 알려줄 수 있습니다.

왜 출처 표시가 중요한가? 신뢰도 측면에서 결정적입니다. "AI가 이렇게 말했어요"보다 "회사 내부 규정 문서 3.2절에 따르면"이 훨씬 더 설득력 있습니다.

사용자는 의심스러운 부분이 있으면 직접 원본 문서를 확인할 수 있습니다. 이는 AI에 대한 맹목적인 신뢰를 막고, 사용자가 주체적으로 판단하도록 돕습니다.

또한 법적 책임 측면에서도 중요합니다. 특히 규제가 엄격한 산업에서는 AI 답변이 어떤 근거를 바탕으로 했는지 감사 기록을 남겨야 합니다.

응답 구조 이해하기 Bedrock의 retrieve_and_generate 응답은 복잡한 구조를 가지고 있습니다. 최상위에는 outputcitations 키가 있습니다.

output에는 생성된 답변 텍스트가 들어있고, citations에는 참조한 문서들의 배열이 들어있습니다. 각 citation 객체는 다시 retrievedReferences 배열을 포함하는데, 여기에 실제 참조 정보가 들어있습니다.

각 reference는 content, location, score 같은 필드를 가집니다. content는 문서에서 추출한 관련 구절, location은 S3 버킷의 파일 경로, score는 질문과의 유사도를 나타냅니다.

유사도 점수의 의미 score 값은 0에서 1 사이의 숫자입니다. 1에 가까울수록 질문과 관련성이 높다는 의미입니다.

예를 들어 0.95는 매우 관련성이 높고, 0.3은 그다지 관련이 없다는 뜻입니다. 일반적으로 0.7 이상이면 신뢰할 만한 참조로 볼 수 있습니다.

실무에서는 점수가 너무 낮은 참조는 표시하지 않는 것이 좋습니다. 사용자에게 혼란을 줄 수 있기 때문입니다.

if cite['score'] > 0.7: 같은 필터를 추가하면 됩니다. Streamlit Secrets 활용 코드에서 st.secrets를 사용한 것을 눈여겨보세요.

API 키나 Knowledge Base ID 같은 민감한 정보를 코드에 직접 작성하면 보안 문제가 발생합니다. Streamlit은 .streamlit/secrets.toml 파일에 이런 정보를 저장하고, st.secrets로 안전하게 접근할 수 있게 합니다.

예를 들어 secrets.tomlKB_ID = "abc123" 같이 작성하면, 코드에서 st.secrets["KB_ID"]로 읽어올 수 있습니다. 이 파일은 절대 Git에 커밋하지 않아야 합니다.

참조 문서 UI 디자인 출처를 어떻게 표시할지도 중요합니다. 답변 바로 아래에 표시하는 것이 일반적이지만, 너무 길면 답변을 읽는 데 방해가 됩니다.

위 코드처럼 구분선을 넣어서 시각적으로 분리하면 좋습니다. st.expander()를 사용해서 "참조 문서 보기" 버튼을 만들어 접었다 펼칠 수 있게 하는 방법도 있습니다.

S3 경로를 클릭 가능한 링크로 현재 코드는 S3 경로를 텍스트로만 표시합니다. 더 나아가려면 Pre-signed URL을 생성해서 사용자가 클릭하면 문서를 직접 볼 수 있게 만들 수 있습니다.

Boto3의 generate_presigned_url 메서드를 사용하면 임시 접근 권한이 있는 URL을 생성할 수 있습니다. 실무 사례 한 법률 서비스 회사는 판례 검색 챗봇을 만들 때 참조 표시를 매우 상세하게 구현했습니다.

판례 번호, 날짜, 법원, 해당 문단까지 모두 표시하고, 클릭하면 대법원 사이트로 연결되게 만들었습니다. 이런 디테일이 서비스의 신뢰도를 크게 높였습니다.

정리 김개발 씨는 참조 문서 표시 기능을 추가한 후 다시 테스트를 받았습니다. "이제 답변을 믿을 수 있겠어요.

의심스러우면 직접 확인할 수 있으니까요." 출처를 명확히 하는 것은 AI 서비스의 기본 원칙입니다. 사용자에게 투명성을 제공하면 신뢰를 얻을 수 있습니다.

실전 팁

💡 - 유사도 점수가 낮은 참조는 필터링해서 표시하지 않는 것이 좋습니다

  • st.expander()로 참조 문서를 접었다 펼칠 수 있게 하면 UI가 깔끔해집니다
  • Pre-signed URL을 생성할 때는 만료 시간을 적절히 설정하세요 (예: 1시간)

5. 출처 표시 UI

참조 문서 정보를 추출하는 데는 성공했지만, 화면에 표시하는 방식이 투박했습니다. S3 경로가 그대로 보이고, 긴 텍스트가 화면을 가득 채웠습니다.

"좀 더 깔끔하고 사용자 친화적으로 만들 수 없을까요?"

출처 표시 UI는 정보의 가독성과 접근성을 높여야 합니다. ExpanderCaption 같은 Streamlit 컴포넌트를 활용하면 공간을 효율적으로 사용하면서도 필요한 정보를 모두 제공할 수 있습니다.

시각적 계층 구조를 명확히 하면 사용자 경험이 크게 향상됩니다.

다음 코드를 살펴봅시다.

import streamlit as st

# 답변 표시
st.markdown(answer)

# 참조 문서를 Expander로 표시 - 접었다 펼칠 수 있음
if citations:
    with st.expander(f"📚 참조 문서 {len(citations)}개 보기"):
        for i, cite in enumerate(citations, 1):
            # 각 참조를 카드처럼 표시
            with st.container():
                col1, col2 = st.columns([3, 1])

                with col1:
                    # 파일 이름 추출 (경로에서 마지막 부분만)
                    filename = cite['location'].split('/')[-1]
                    st.markdown(f"**{i}. {filename}**")

                    # 관련 구절 미리보기 (처음 150자만)
                    preview = cite['content'][:150]
                    if len(cite['content']) > 150:
                        preview += "..."
                    st.caption(preview)

                with col2:
                    # 유사도 점수를 색상으로 표시
                    score = cite['score']
                    if score >= 0.8:
                        st.success(f"관련도\n{score:.0%}")
                    elif score >= 0.6:
                        st.warning(f"관련도\n{score:.0%}")
                    else:
                        st.info(f"관련도\n{score:.0%}")

                st.divider()  # 구분선

김개발 씨가 만든 첫 번째 버전은 기능적으로는 완벽했지만, UI가 산만했습니다. 참조 문서가 3개만 있어도 화면을 스크롤해야 했고, S3 경로가 너무 길어서 가독성이 떨어졌습니다.

디자이너 출신인 팀원 최디자인 씨가 피드백을 주었습니다. "정보는 다 있는데, 시각적 계층이 없어요.

뭐가 중요한지 한눈에 안 들어와요." 박시니어 씨도 동의했습니다. "참조 문서는 관심 있는 사람만 보니까, 펼쳐보기 형식으로 숨겨두는 게 어때?" UI 계층 구조의 중요성 쉽게 비유하자면, UI는 마치 신문 기사와 같습니다.

신문은 헤드라인, 본문, 각주로 구성되어 있고, 각각 다른 크기와 스타일로 표시됩니다. 독자는 헤드라인만 훑어보고 관심 있는 기사만 자세히 읽습니다.

웹 UI도 마찬가지입니다. 가장 중요한 정보는 크고 명확하게, 부가 정보는 작고 절제되게 표시해야 합니다.

모든 정보가 동일한 비중으로 표시되면 사용자는 무엇을 먼저 봐야 할지 혼란스러워집니다. Expander의 활용 st.expander()는 정보를 접었다 펼칠 수 있게 만드는 컴포넌트입니다.

기본적으로 접혀있어서 화면을 차지하지 않고, 제목만 표시됩니다. 사용자가 클릭하면 내용이 펼쳐집니다.

이는 선택적 정보를 표시할 때 매우 효과적입니다. 모든 사용자가 참조 문서를 확인하는 것은 아니므로, 필요한 사람만 펼쳐보면 됩니다.

제목에 이모지를 추가하면 시각적으로 더 눈에 띕니다. 📚 같은 책 이모지는 참조 문서를 직관적으로 나타냅니다.

또한 {len(citations)}개 보기처럼 개수를 표시하면 사용자가 얼마나 많은 참조가 있는지 미리 알 수 있습니다. 컬럼 레이아웃 st.columns()는 화면을 여러 열로 나눕니다.

위 코드에서 [3, 1]은 왼쪽 열이 오른쪽 열보다 3배 넓다는 의미입니다. 왼쪽에는 파일 이름과 내용 미리보기를, 오른쪽에는 유사도 점수를 배치했습니다.

이렇게 하면 정보가 수평으로 정리되어 공간을 효율적으로 사용할 수 있습니다. 신문이나 잡지도 여러 열로 나뉘어 있는 것과 같은 원리입니다.

사람의 눈은 긴 가로줄보다 짧은 세로줄을 읽을 때 더 편안함을 느낍니다. 파일 경로 처리 S3 경로는 보통 s3://my-bucket/documents/legal/contract_2024.pdf 같은 형식입니다.

이 전체 경로를 표시하면 너무 길고 읽기 어렵습니다. split('/')[-1]을 사용하면 마지막 부분인 파일 이름만 추출할 수 있습니다.

위 예시에서는 contract_2024.pdf만 표시됩니다. 훨씬 깔끔하고 의미 있는 정보입니다.

전체 경로가 필요하면 st.caption()으로 작은 글씨로 추가하거나, 툴팁에 넣을 수 있습니다. 텍스트 미리보기 참조 문서의 내용이 수천 자일 수도 있습니다.

전체를 표시하면 화면이 지나치게 길어집니다. 처음 150자만 보여주고 말줄임표를 추가하면, 사용자가 대략적인 내용을 파악할 수 있으면서도 UI가 깔끔하게 유지됩니다.

전체 내용이 필요하면 또 다른 expander를 중첩하거나, 모달 창을 띄우는 방법도 있습니다. 유사도 점수의 시각화 숫자만 표시하는 것보다 색상을 사용하면 직관적입니다.

Streamlit의 st.success(), st.warning(), st.info()는 각각 초록색, 노란색, 파란색 박스를 만듭니다. 0.8 이상은 초록색으로 "매우 관련 있음", 0.6~0.8은 노란색으로 "관련 있음", 그 이하는 파란색으로 "참고용"처럼 구분할 수 있습니다.

사용자는 숫자를 해석할 필요 없이 색상만 보고도 신뢰도를 판단할 수 있습니다. 신호등처럼 초록색은 안전, 노란색은 주의, 빨간색은 위험이라는 직관적인 연상을 활용하는 것입니다.

구분선의 활용 st.divider()는 얇은 수평선을 그립니다. 여러 참조 문서가 나열될 때, 구분선이 없으면 어디서 하나가 끝나고 다른 하나가 시작하는지 구분하기 어렵습니다.

구분선을 추가하면 각 항목이 독립적인 카드처럼 보여서 가독성이 크게 향상됩니다. 반응형 디자인 고려 컬럼 레이아웃은 화면 크기에 따라 자동으로 조정됩니다.

데스크톱에서는 가로로 나란히 표시되지만, 모바일에서는 세로로 쌓일 수 있습니다. Streamlit이 자동으로 처리해주지만, 테스트는 필수입니다.

개발자 도구로 모바일 화면을 시뮬레이션해서 확인해보세요. 실무 개선 사례 한 스타트업은 초기에 참조 문서를 간단한 텍스트로만 표시했습니다.

사용자 피드백을 받아서 위와 같은 카드 형식으로 개선한 결과, 사용자가 참조 문서를 확인하는 비율이 3배 증가했습니다. 시각적 개선만으로도 기능 활용도가 크게 높아진 사례입니다.

정리 김개발 씨는 최디자인 씨의 도움을 받아 UI를 개선했습니다. "와, 이제 정말 전문 서비스처럼 보이네요!" 팀원들의 반응도 훨씬 좋았습니다.

기능이 완벽해도 UI가 조잡하면 사용자는 신뢰하지 않습니다. 작은 디테일이 서비스의 품질을 결정합니다.

실전 팁

💡 - Expander는 기본적으로 접혀있지만, expanded=True 옵션으로 기본 펼침 상태로 만들 수 있습니다

  • 유사도 기준값(0.8, 0.6)은 도메인에 따라 조정하세요
  • st.container()로 요소들을 그룹화하면 CSS 스타일을 일괄 적용하기 쉽습니다

6. 스트리밍 응답 표시

기능은 모두 완성되었지만, 김개발 씨는 한 가지 더 개선하고 싶었습니다. 긴 답변의 경우 5초 이상 아무것도 안 보이다가 갑자기 전체 텍스트가 나타났습니다.

"ChatGPT처럼 답변이 타이핑되듯이 나타나면 더 좋을 것 같은데요?"

스트리밍 응답은 텍스트를 한 번에 표시하지 않고, 생성되는 대로 순차적으로 보여주는 방식입니다. 마치 사람이 타이핑하듯이 글자가 하나씩 나타나서 사용자가 기다리는 동안 지루함을 덜 느낍니다.

Bedrock의 retrieve_and_generate API도 스트리밍을 지원합니다.

다음 코드를 살펴봅시다.

import streamlit as st

# 스트리밍 응답 처리 함수
def get_streaming_response(prompt):
    # 스트리밍 모드로 API 호출
    response = bedrock_agent.retrieve_and_generate(
        input={'text': prompt},
        retrieveAndGenerateConfiguration={
            'type': 'KNOWLEDGE_BASE',
            'knowledgeBaseConfiguration': {
                'knowledgeBaseId': st.secrets["KB_ID"],
                'modelArn': st.secrets["MODEL_ARN"]
            }
        }
    )

    # invoke_with_response_stream 방식 사용
    stream = response.get('stream')
    return stream

# 사용자 입력 처리
if prompt := st.chat_input("질문을 입력하세요"):
    # 사용자 메시지 저장 및 표시
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # AI 응답을 스트리밍으로 표시
    with st.chat_message("assistant"):
        message_placeholder = st.empty()
        full_response = ""

        # 스트림에서 청크를 하나씩 받아 표시
        for chunk in get_streaming_response(prompt):
            if 'chunk' in chunk:
                text = chunk['chunk'].get('bytes', b'').decode()
                full_response += text
                # 커서 효과를 위해 '▌' 추가
                message_placeholder.markdown(full_response + "▌")

        # 최종 응답 (커서 제거)
        message_placeholder.markdown(full_response)

    # 응답 저장
    st.session_state.messages.append({"role": "assistant", "content": full_response})

김개발 씨는 ChatGPT를 사용하다가 답변이 타이핑되듯이 나타나는 것을 보고 감동했습니다. "우리 챗봇도 저렇게 만들 수 없을까?" 박시니어 씨에게 물었더니 "가능해.

Bedrock도 스트리밍을 지원하거든"이라는 답이 돌아왔습니다. 스트리밍이란 무엇인가? 쉽게 비유하자면, 스트리밍은 마치 영화를 보는 것과 같습니다.

과거에는 영화 파일 전체를 다운로드한 후에야 재생할 수 있었습니다. 하지만 Netflix 같은 스트리밍 서비스는 다운로드하면서 동시에 재생합니다.

사용자는 기다리지 않고 바로 영화를 볼 수 있습니다. 텍스트 생성도 마찬가지입니다.

LLM이 전체 답변을 생성할 때까지 기다리지 않고, 단어나 문장이 생성되는 즉시 화면에 표시합니다. 왜 스트리밍이 중요한가? 사용자 경험 측면에서 결정적인 차이를 만듭니다.

긴 답변의 경우 전체 생성에 10초 이상 걸릴 수 있습니다. 스피너만 보면서 10초를 기다리는 것은 매우 답답합니다.

하지만 1초 후부터 답변이 나타나기 시작하면, 사용자는 기다리는 동안 먼저 나온 부분을 읽으며 시간을 보낼 수 있습니다. 심리학 연구에 따르면, 진행 상황이 보이면 사람들은 실제보다 시간이 덜 걸린다고 느낍니다.

엘리베이터에 층수 표시가 있는 것과 같은 원리입니다. Bedrock 스트리밍 API AWS Bedrock은 두 가지 응답 방식을 제공합니다.

일반 모드는 전체 응답이 준비되면 한 번에 반환합니다. 스트리밍 모드는 생성되는 대로 청크(작은 조각)를 순차적으로 전송합니다.

스트리밍을 활성화하려면 API 호출 시 특정 옵션을 설정해야 합니다. 응답은 제너레이터(generator) 형태로 반환됩니다.

Python의 제너레이터는 값을 하나씩 순차적으로 생성하는 특수한 함수입니다. for 루프로 순회하면서 각 청크를 받아 처리할 수 있습니다.

Streamlit의 플레이스홀더 st.empty()는 나중에 내용을 채울 수 있는 빈 공간을 만듭니다. 일반적인 st.markdown()은 한 번 실행되면 화면에 고정되고 수정할 수 없습니다.

하지만 st.empty()로 만든 플레이스홀더는 반복해서 업데이트할 수 있습니다. 스트리밍 텍스트를 표시할 때 핵심적인 역할을 합니다.

루프를 돌면서 message_placeholder.markdown(full_response + "▌")를 계속 호출하면, 같은 위치의 텍스트가 계속 업데이트됩니다. 마치 애니메이션처럼 보입니다.

커서 효과 구현 "▌" 문자는 타이핑 중임을 나타내는 커서입니다. 텍스트 끝에 이 문자를 추가하면, 실제로 누군가 타이핑하는 것처럼 보입니다.

ChatGPT도 동일한 효과를 사용합니다. 최종 응답이 완성되면 커서를 제거해서 깔끔하게 마무리합니다.

다른 문자를 사용할 수도 있습니다. "●", "...", "_" 등 원하는 스타일로 커스터마이징할 수 있습니다.

청크 처리 로직 스트림에서 받는 각 청크는 바이트 형태입니다. .decode()를 사용해 문자열로 변환한 후 기존 텍스트에 추가합니다.

full_response += text로 계속 누적하면서, 매번 전체 텍스트를 화면에 업데이트합니다. 이렇게 하면 새로 추가된 부분만 나타나는 것처럼 보입니다.

오류 처리도 중요합니다. 네트워크 문제로 스트림이 중단될 수 있으므로, try-except로 감싸고 부분 응답이라도 표시해야 합니다.

성능 고려사항 너무 자주 화면을 업데이트하면 오히려 느려질 수 있습니다. Bedrock은 보통 수십~수백 바이트씩 청크를 보냅니다.

매 청크마다 화면을 업데이트하면 초당 수십 번 리렌더링이 발생합니다. 실무에서는 청크를 일정 크기로 모았다가 업데이트하거나, 시간 기반으로 제한(예: 0.1초마다 한 번)을 거는 것이 좋습니다.

접근성 고려 스크린 리더를 사용하는 시각장애인 사용자에게는 스트리밍이 오히려 불편할 수 있습니다. 텍스트가 계속 변경되면 스크린 리더가 계속 읽기를 재시작하기 때문입니다.

접근성을 중요시한다면, 스트리밍 모드를 켜고 끌 수 있는 옵션을 제공하는 것이 좋습니다. 실무 사례 한 고객 지원 챗봇 서비스는 스트리밍을 도입한 후 사용자 만족도가 눈에 띄게 상승했습니다.

특히 복잡한 기술 문서를 요약하는 기능에서 효과가 컸습니다. 사용자들은 "응답이 빠르다"고 느꼈고, 실제로는 총 응답 시간은 동일했습니다.

정리 김개발 씨는 스트리밍 기능을 추가한 후 팀 전체에 데모를 했습니다. "우와, ChatGPT처럼 되네요!" 모두가 감탄했습니다.

작은 차이지만 전체 경험을 크게 바꾸는 개선이었습니다. 사용자 경험은 대기 시간 자체보다 대기 시간을 어떻게 느끼는가가 더 중요합니다.

스트리밍은 그 인식을 바꾸는 강력한 도구입니다.

실전 팁

💡 - 청크가 너무 작으면 업데이트 오버헤드가 크므로, 적절한 버퍼 크기를 찾으세요

  • time.sleep(0.01) 같은 작은 지연을 추가하면 더 자연스러운 타이핑 효과를 낼 수 있습니다
  • 오류 발생 시 부분 응답이라도 저장해서 사용자가 다시 시도할 때 참고할 수 있게 하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#Streamlit#RAG#ChatUI#KnowledgeBase#AWS

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.