LLMOps 36

Streamlit으로 Agent 결과 대시보드를 만들고 공개하기

대시보드는 프론트엔드 지식이 있어야만 만든다고 생각하기 쉽다.

근거 · 교안 p142-p154

Streamlit으로 Agent 결과 대시보드를 만들고 공개하기 대표 이미지

1장: Python만으로 UI 만들기: Streamlit의 마법

솔라는 노트북 화면에 떠 있는 몇 줄의 문장을 멍하니 바라보고 있었다. 최근 관심이 생긴 AI 에이전트 프로젝트의 진행 상황을 공유할 방법을 찾다가 우연히 발견한 도구에 대한 설명이었다.

"Streamlit: Python 기반 데이터 앱 대시보드 프레임워크. HTML/JS 없이 Python 코드만으로 UI 생성. 'streamlit run app.py'로 웹서버 실행."

솔라는 고개를 갸웃거렸다. 단어 하나하나는 알겠지만, 문장 전체가 주는 느낌은 어딘가 비현실적이었다. ‘파이썬으로 UI를 만든다고? 웹페이지를 만들려면 당연히 HTML로 뼈대를 만들고, CSS로 디자인하고, 자바스크립트로 동작을 제어해야 하는 거 아니었나?’

솔라의 머릿속에서 ‘대시보드’라는 단어는 곧 복잡한 프론트엔드 코드의 산물과 동의어였다. Python은 데이터를 처리하고 AI 모델을 돌리는, 말하자면 무대 뒤의 역할이라고만 생각했다. 그런데 이 설명은 파이썬이 조명과 무대장치까지 전부 만든다는 것처럼 들렸다.

“언니, 이거 좀 이상하지 않아?”

거실 반대편에서 책을 읽던 루나가 시선을 돌렸다.

“뭐가?”

“이거. 스트림릿이라는 건데, 파이썬 코드만으로 UI를 만든대. 웹페이지를. 프론트엔드 지식 없이 가능하다는 게… 말이 돼?”

솔라의 목소리에는 ‘그럴 리 없다’는 확신이 섞여 있었다. 분명 ‘복잡한 자바스크립트 프레임워크를 안 써도 된다’는 말을 과장해서 표현한 것일 거라고 짐작했다.

루나는 잠시 생각하더니 조용히 자리에서 일어났다. 솔라의 옆에 앉아 노트북 화면을 들여다보는 대신, 솔라의 손을 키보드 위로 이끌었다.

“말로 설명하는 것보다, 직접 한번 해보는 게 빠를 거야. 코드 에디터 열고, app.py라는 이름으로 새 파일 하나 만들어볼래?”

추상적인 설명 대신 구체적인 행동을 요구하는 언니의 방식이 솔라는 익숙했다. 솔라는 투덜거리면서도 빈 파일을 하나 열었다.

“파일은 만들었는데, 뭘 쓰라고? HTML 태그라도 쓰나?”

“아니. 딱 세 줄만 써봐.”

루나가 불러주는 코드는 어이가 없을 정도로 간단했다.

import streamlit as st

st.title("나의 첫 대시보드")
st.write("이건 파이썬 코드로 만들었어요!")

“이게 다야? 이걸로 어떻게 웹페이지가 돼?”

솔라는 코드를 뜯어봤다. import streamlit은 라이브러리를 가져오는 평범한 구문. st.title이나 st.write는 어딘가 print 함수와 비슷해 보였다. 제목을 출력하고, 문장을 출력하고. 이게 전부였다.

“이제 터미널을 열고, 그 파일이 있는 폴더에서 이렇게 한번 입력해봐.”

루나가 가리킨 명령어는 아까 솔라가 읽었던 바로 그 문장이었다.

streamlit run app.py

솔라는 반신반의하며 키보드에 명령어를 입력하고 엔터 키를 눌렀다. 잠시 후, 터미널에 몇 줄의 로그가 뜨더니 저절로 새 웹 브라우저 창이 화면에 나타났다.

그리고 그 창 안에는…

나의 첫 대시보드 이건 파이썬 코드로 만들었어요!

방금 전 app.py 파일에 입력했던 내용이 제목과 본문으로 예쁘게 표시된 웹페이지가 떠 있었다.

“어…?”

솔라는 눈을 비볐다. 마법 같았다. HTML 파일도, CSS 파일도, 하다못해 <script> 태그 한 줄도 없었다. 오직 몇 줄의 파이썬 코드와 명령어 하나가 전부였다. 그런데 온전한 웹페이지가 자신의 로컬 서버에서 실행되고 있었다. 브라우저 주소창에는 http://localhost:8501 이라는 낯선 주소가 찍혀 있었다.

“세상에… 진짜 되네? 내 컴퓨터에서 웹서버가 돌아가고 있는 거야? 그리고 이 화면은 누가 만든 거지?”

솔라의 혼란스러운 질문에 루나가 차분하게 화면의 한쪽을 가리켰다.

“우리가 방금 실행한 streamlit이라는 명령어가 작은 웹서버를 띄운 거야. 그리고 app.py 파일을 읽어서, st.title 같은 함수들을 만나면 그걸 웹페이지에서 보이는 HTML 코드로 바꿔서 브라우저에게 보내주는 거지. 우리는 파이썬 함수만 불렀지만, 스트림릿이 알아서 프론트엔드 작업을 대신 해준 셈이야.”

그제야 솔라는 자신이 가졌던 오해의 정체를 깨달았다. 스트림릿은 단순히 파이썬 ‘라이브러리’가 아니었다. 파이썬 코드를 웹 UI로 번역하고, 그 UI를 보여줄 웹서버까지 직접 실행해주는 하나의 ‘실행기’이자 ‘생성기’였다. 프론트엔드 개발자가 하던 역할을 파이썬 개발자가 익숙한 방식으로 할 수 있게 다리를 놓아준 것이다. 더 이상 웹 기술의 복잡함 뒤에 숨을 필요가 없었다.

솔라는 방금 전까지 불가능하다고 여겼던 일이 눈앞에서 벌어진 것을 보며 흥분을 감추지 못했다. 파이썬만으로 UI를 만들 수 있는 도구를 손에 넣은 기분이었다.

“와, 대단하다. 그럼 이제 이걸로 뭐든 만들 수 있는 거네!”

솔라는 잠시 들떴다가, 이내 화면을 다시 보며 한 가지 의문을 떠올렸다.

“그런데 언니, 이건 그냥 글자만 보여주는 거잖아. 내가 만들고 싶은 건 ‘에이전트 운영 결과 대시보드’란 말이야. 복잡한 데이터 표도 보여주고, 사용자가 뭘 선택하면 결과가 바뀌는 그래프도 있어야 하는데… 과연 그런 것까지 가능할까?”

2장: 데이터 시각화 요소 추가하기: 정보의 흐름을 보여주는 대시보드

솔라는 자신의 app.py 파일과 그 코드가 만들어낸 단출한 웹페이지를 번갈아 보고 있었다. ‘나의 첫 대시보드’라는 제목 아래 찍힌 문장 하나. 파이썬만으로 UI를 만드는 마법은 확인했지만, 솔라의 마음 한구석에는 여전히 의심이 남아 있었다. ‘대시보드’라고 부르기엔 너무나 허전한 화면이었다.

솔라의 머릿속에 있는 ‘에이전트 운영 결과 대시보드’는 이런 모습이 아니었다. 에이전트들의 성공률, 평균 응답 시간, 토큰 사용량 같은 지표들이 담긴 데이터 표가 있어야 했다. 그리고 날짜별, 에이전트 종류별 성능 추이를 한눈에 보여주는 그래프도 필수였다. 솔라는 st.write라는 함수가 과연 print처럼 단순히 글자만 찍어내는 기능일 것이라고 단정했다. 복잡한 표나 그림을 보여주려면 분명 다른 복잡한 과정이 필요할 터였다.

‘한번 시험해볼까?’

솔라는 의심을 확인하기 위해 직접 코드를 수정하기 시작했다. AI 에이전트의 가상 운영 로그를 담은 간단한 데이터 표를 파이썬 코드로 직접 만들어보기로 했다. 데이터 분석에 익숙한 판다스(Pandas) 라이브러리를 사용하는 것은 순식간이었다.

import streamlit as st
import pandas as pd  # 판다스 라이브러리 추가

st.title("에이전트 운영 결과 대시보드")

# 가상 데이터 생성
data = {
    'agent_name': ['검색 에이전트', '요약 에이전트', '검색 에이전트', '코드 에이전트', '요약 에이전트'],
    'success_rate': [0.95, 0.99, 0.89, 0.92, 0.97],
    'avg_response_time': [1.2, 0.8, 1.5, 2.1, 0.9]
}
df = pd.DataFrame(data)

st.write("에이전트별 성능 지표")
st.write(df) # 데이터프레임을 직접 전달

솔라는 마지막 줄을 입력하며 피식 웃었다. st.write(df)라니. 자신의 예상대로라면, 브라우저 화면에는 데이터프레임 객체의 알아볼 수 없는 문자열 정보나, 기껏해야 정렬도 되지 않은 데이터 값들이 지저분하게 출력될 것이었다. 파일을 저장하자마자 스트림릿 앱이 자동으로 재실행되며 브라우저 화면이 새로고침되었다.

솔라의 눈이 동그래졌다.

화면에는 깔끔하게 정돈된 표가 나타나 있었다. 열 이름은 굵게 표시되어 있었고, 각 행과 열은 보기 좋게 구분되어 있었다. 심지어 표의 오른쪽 위에는 전체 화면으로 보거나 데이터를 검색할 수 있는 작은 아이콘까지 달려 있었다. 자신이 기대했던 ‘지저분한 텍스트 뭉치’와는 차원이 다른 결과물이었다.

“언니! 이거 봐! 내가 생각했던 거랑 완전 달라!”

솔라의 외침에 루나가 조용히 다가왔다. 솔라는 흥분한 목소리로 방금 일어난 일을 설명했다.

st.write가 그냥 print 같은 건 줄 알았어. 그래서 데이터프레임을 넣으면 엉망으로 나올 거라고 생각했는데… 이건 그냥 표가 아니라 거의 ‘작은 엑셀’ 같아.”

루나는 고개를 끄덕이며 솔라의 코드를 가리켰다.

“솔라 네가 넘겨준 게 pd.DataFrame 객체라는 걸 스트림릿이 알아차린 거야. st.write는 똑똑해서, 입력으로 들어온 데이터의 종류를 보고 가장 적절한 방식으로 화면에 그려줘. 평범한 글자(string)를 주면 그냥 글자로 보여주고, 데이터프레임을 주면 지금처럼 멋진 표로 만들어주는 거지.”

솔라는 그제야 st.write의 진가를 깨달았다. 개발자가 데이터의 종류에 따라 일일이 다른 출력 함수를 고민할 필요 없이, 일단 st.write에 던져주면 알아서 최선을 다해 보여주는 마법 같은 도구였던 것이다.

자신감을 얻은 솔라는 한 걸음 더 나아가기로 했다. 대시보드의 꽃, 그래프를 그려볼 차례였다.

“좋아. 표가 됐으니 그래프도 되겠지?”

솔라는 익숙하게 matplotlib 라이브러리를 사용해 에이전트별 성공률을 보여주는 간단한 막대그래프를 그리는 코드를 추가했다.

import matplotlib.pyplot as plt

# ... (이전 코드와 동일) ...

st.write("에이전트별 성능 지표")
st.write(df)

# 그래프 생성
fig, ax = plt.subplots()
ax.bar(df['agent_name'], df['success_rate'])
ax.set_ylabel('Success Rate')
ax.set_title('Success Rate by Agent')

# st.write(fig) # 이걸로는 안 되지 않을까?

코드를 작성하던 솔라는 잠시 멈칫했다. matplotlib이 만든 fig 객체도 st.write가 알아서 그려줄까? 방금 전의 경험으로 기대감이 생겼지만, 그림 객체는 데이터프레임과는 또 다른 문제일 것 같았다.

그때 루나가 힌트를 주었다.

“그래프처럼 특별한 종류의 결과물은 전용 함수가 있을 때가 많아. pyplot으로 만든 그림이니까, st.pyplot이라고 한번 찾아볼래?”

솔라는 st.write(fig)라고 썼던 줄을 지우고 st.pyplot(fig)으로 바꿨다. 파일을 저장하자 다시 웹페이지가 갱신되었다. 잠시 후, 데이터 표 아래에 방금 코드로 그린 막대그래프가 선명하게 나타났다.

파이썬 코드만으로, 단 두 줄의 스트림릿 함수(st.writest.pyplot)를 이용해 데이터 표와 그래프가 모두 포함된 웹페이지가 완성된 것이다. 프론트엔드 코드는 여전히 단 한 줄도 필요 없었다.

솔라는 완성된 화면을 보며 만족스러운 미소를 지었다. 파이썬의 익숙한 데이터 처리 방식과 시각화 도구를 그대로 활용하면서도, 결과물은 웹 대시보드의 형태로 즉시 나타났다. 스트림릿은 단순한 UI 생성기를 넘어, 데이터의 흐름을 보여주는 강력한 시각화 도구이기도 했다.

하지만 솔라는 이내 새로운 의문이 들었다. 지금 만든 대시보드는 모든 데이터를 한 번에 보여줄 뿐이었다.

“언니, 멋지긴 한데 이건 정적인 보고서 같아. 만약 사용자가 ‘요약 에이전트’의 결과만 골라서 보고 싶어 하면 어떡하지? 필터 같은 걸 달아서 내가 원하는 데이터만 선택하고, 그에 맞춰서 그래프도 바뀌게 만들 수는 없을까?”

3장: 대화형 위젯으로 상호작용성 부여하기: 사용자와 소통하는 대시보드

솔라의 노트북 화면은 두 개의 창으로 나뉘어 있었다. 왼쪽에는 app.py 코드가, 오른쪽에는 방금 만든 ‘에이전트 운영 결과 대시보드’가 띄워져 있었다. 표와 그래프가 제법 그럴듯한 모양새를 갖췄지만, 솔라의 손가락은 키보드 위에서 만족을 모른 채 방황했다. 그녀의 시선은 화면 위쪽의 비어있는 공간에 머물러 있었다. ‘여기에 필터를 넣어야 하는데.’

솔라는 웹 브라우저에 새 탭을 열고 검색창에 ‘streamlit button click event’, ‘streamlit handle user input’ 같은 키워드를 연달아 입력했다. 검색 결과로 나온 페이지들은 on_click 콜백 함수나 st.session_state 같은 낯선 개념들을 언급하고 있었다. 솔라의 미간이 좁아졌다. 역시 세상에 공짜는 없었다. 파이썬만으로 UI를 쉽게 만드는 대가로, 상호작용을 구현하려면 결국 웹 개발에서 마주쳤던 복잡한 ‘상태 관리’와 ‘이벤트 처리’의 세계로 다시 들어가야 하는 것 같았다.

“언니, 이거 봐. 결국 복잡해지는 건 똑같잖아.”

솔라는 투덜거리며 자신의 검색 결과를 루나에게 보여주었다.

“사용자가 뭔가를 선택하는 기능을 만들려면, 그 선택을 기억하고 있다가 버튼을 누르면 반응하는 함수를 따로 만들어 연결해야 한대. 자바스크립트에서 이벤트 리스너 다는 거랑 뭐가 달라.”

솔라의 목소리에는 실망감이 묻어났다. 그녀는 파이썬의 간결함 속에서 모든 것이 해결되리라 기대했지만, 사용자 입력이라는 벽에 부딪히자 다시 프론트엔드 개발의 복잡성이 떠올랐기 때문이다.

루나는 솔라의 화면을 잠시 들여다보더니, 복잡한 콜백 함수 설명 대신 app.py 코드 파일을 가리켰다.

“그 방법도 있지만, 더 스트림릿다운 방식이 있어. 사용자가 여러 개 중에 고르게 하고 싶다고 했지? 그럼 ‘다중 선택’을 뜻하는 함수가 있는지 한번 찾아보는 건 어때?”

“다중 선택? Multiselect?”

솔라는 반신반의하며 코드 맨 윗부분, st.title 바로 아래에 새로운 줄을 추가했다. st.multiselect 라고 입력하자 코드 에디터가 자동으로 함수의 매개변수들을 보여주었다. 첫 번째는 label, 두 번째는 options. 직관적이었다. 솔라는 에이전트 종류를 선택할 수 있는 필터를 만들기로 마음먹고 코드를 작성했다.

# ... (import 부분) ...

st.title("에이전트 운영 결과 대시보드")

# 사용자가 선택할 수 있는 옵션들
agent_options = df['agent_name'].unique()

# 멀티셀렉트 위젯 추가
selected_agents = st.multiselect(
    '보고 싶은 에이전트를 선택하세요:',
    agent_options,
    default=agent_options # 기본값으로 전체 선택
)

# ... (데이터프레임 생성 부분) ...

솔라는 코드를 저장하고 브라우저를 쳐다봤다. 화면이 한번 깜빡이더니, 제목 아래에 ‘보고 싶은 에이전트를 선택하세요:’ 라는 문구와 함께 깔끔한 선택창이 나타났다. 클릭하자 ‘검색 에이전트’, ‘요약 에이전트’ 등의 선택지들이 펼쳐졌다.

“오, 위젯이 생기긴 했네. 근데 이걸로 어떻게 데이터를 바꾼담?”

솔라는 아직 핵심을 파악하지 못했다. 그녀의 머릿속에는 여전히 ‘선택된 값으로 무엇을 할지 명령하는 별도의 로직’이 필요하다는 생각이 자리 잡고 있었다. 그때 루나가 selected_agents 라는 변수명을 짚었다.

“그 함수가 무언가를 돌려주지 않았을까? selected_agents 변수에 뭐가 들어있는지 한번 출력해봐.”

솔라는 st.write(selected_agents) 한 줄을 추가하고 파일을 저장했다. 브라우저의 선택창에서 ‘검색 에이전트’와 ‘코드 에이전트’ 두 개를 고르자, 선택창 바로 아래에 ['검색 에이전트', '코드 에이전트'] 라는 리스트가 나타났다. 선택을 바꿀 때마다 리스트의 내용도 실시간으로 바뀌었다.

그 순간, 솔라의 머릿속에 흩어져 있던 조각들이 맞춰졌다.

“아…! 잠깐만. 그럼…?”

솔라는 숨 가쁘게 키보드를 두드렸다. 더 이상 복잡한 이벤트 핸들러나 콜백 함수는 필요 없었다. 생각의 흐름은 지극히 파이썬다웠다.

  1. st.multiselect 위젯으로 사용자에게 선택지를 보여준다.
  2. 사용자가 고른 선택지들은 파이썬 리스트(selected_agents)로 반환된다.
  3. 이 리스트를 사용해서 원래의 데이터프레임을 필터링한다.
  4. 필터링된 데이터프레임을 화면에 그린다.
# ... (st.multiselect 부분까지 동일) ...

# 선택된 에이전트로 데이터 필터링
filtered_df = df[df['agent_name'].isin(selected_agents)]

# 필터링된 데이터를 화면에 표시
st.write("에이전트별 성능 지표")
st.write(filtered_df)

# 필터링된 데이터로 그래프 생성
fig, ax = plt.subplots()
ax.bar(filtered_df['agent_name'], filtered_df['success_rate'])
ax.set_ylabel('Success Rate')
ax.set_title('Success Rate by Agent')
st.pyplot(fig)

솔라는 코드를 완성하고 떨리는 마음으로 파일을 저장했다. 브라우저에서 ‘요약 에이전트’의 선택을 해제하자, 마법 같은 일이 벌어졌다. 화면의 데이터 표와 막대그래프에서 ‘요약 에이전트’에 관련된 모든 정보가 순식간에 사라졌다. 다시 선택하자 원래대로 돌아왔다. 모든 것이 사용자의 클릭에 즉시 반응했다.

“됐다! 언니, 이거 봐! 내가 뭘 선택하는지에 따라 표랑 그래프가 실시간으로 바뀌어!”

솔라는 자신이 만든 대화형 대시보드를 보며 감탄했다. 프론트엔드 지식 없이, 복잡한 상태 관리 로직 없이, 그저 파이썬 코드를 위에서 아래로 순서대로 실행하는 것만으로 상호작용이 구현된 것이다. 스트림릿은 사용자가 위젯을 조작할 때마다 스크립트 전체를 다시 실행하고, 그 과정에서 위젯의 현재 값을 변수에 담아줌으로써 이 모든 것을 가능하게 했다. 개발자는 그냥 그 변수를 가져다 쓰기만 하면 되었다.

“정말 간단하네. 사용자가 뭘 하는지 감시하고 있다가 반응하는 게 아니라, 그냥 사용자가 뭔가를 할 때마다 백지에서 그림을 새로 그리는 방식이었구나.”

솔라는 스트림릿의 작동 원리를 완벽히 이해했다. 하지만 그 명쾌함 속에서 새로운 의문이 고개를 들었다.

“그런데 언니, 지금은 데이터가 몇 줄 안 돼서 괜찮지만… 만약 내가 수백만 줄짜리 거대한 로그 파일을 읽어서 이 데이터프레임을 만들어야 한다면 어떡하지? 필터에서 에이전트 하나 뺐다 넣을 때마다 매번 그 큰 파일을 다시 읽어야 하는 거잖아? 그럼 엄청 느려지지 않을까?”

4장: 성능 최적화와 레이아웃 구성: 빠르고 보기 좋은 대시보드

솔라는 자신의 손가락을 원망스럽게 쳐다보았다. 방금 전까지만 해도 ‘대화형 위젯’이라는 새로운 마법을 손에 넣어 세상을 다 가진 듯 기뻤는데, 그 기쁨은 채 5분을 가지 못했다. 문제는 그녀의 대시보드가 너무나도 ‘성실하게’ 반응한다는 점이었다. 필터에서 에이전트 하나를 추가하거나 뺄 때마다, 화면 전체가 잠시 멈칫하며 새로운 결과를 그려냈다. 지금은 데이터가 고작 다섯 줄이라 멈칫하는 수준이지만, 솔라의 머릿속은 이미 수백만 줄의 로그 데이터를 상상하고 있었다.

그녀는 직접 그 ‘지옥’을 체험해 보기로 했다. 실제 데이터를 불러오는 대신, 데이터를 생성하는 함수에 의도적으로 지연을 추가했다. 마치 아주 큰 파일을 읽거나 복잡한 데이터베이스에서 조회하는 것처럼.

import time

def load_data():
    # 3초 동안 멈춰서 무거운 작업을 시뮬레이션
    print("데이터 로딩 중...")
    time.sleep(3) 
    # 기존 데이터 생성 로직
    data = { ... } 
    return pd.DataFrame(data)

df = load_data()

코드를 수정하고 저장하자마자, 스트림릿은 앱을 재실행했다. 브라우저 오른쪽 상단에 ‘Running…’이라는 작은 알림과 함께 회전하는 아이콘이 나타났다. 3초가 지나자 드디어 익숙한 대시보드 화면이 떴다. 솔라는 심호흡을 하고 필터에서 ‘코드 에이전트’의 체크를 해제했다.

또다시 ‘Running…’ 아이콘이 나타났다. 1초, 2초, 3초… 화면은 멈춰 있고, 터미널에는 ‘데이터 로딩 중…’이라는 메시지가 다시 한번 찍혔다. 3초가 지나서야 비로소 그래프와 표에서 ‘코드 에이전트’가 사라진 결과가 나타났다. 솔라는 ‘요약 에이전트’를 다시 체크했다. 결과는 마찬가지였다. 클릭 한 번에 3초의 기다림. 이것은 ‘대화형’ 대시보드가 아니라 ‘인내심 테스트’에 가까웠다.

“언니, 이거 봐. 내 우려가 현실이 됐어.”

솔라는 거의 울상이었다.

“필터 하나 바꿀 때마다 3초씩 걸려. 스트림릿이 스크립트 전체를 다시 실행하니까 load_data() 함수도 매번 새로 호출하는 거야. 실제 데이터였으면 3초가 아니라 30초가 걸릴 수도 있어. 이러면 아무도 안 써.”

솔라의 머릿속은 복잡해졌다. 이런 문제를 해결하는 기술을 얼핏 들어본 적이 있었다. ‘캐싱(Caching)’. 한번 계산한 결과를 어딘가에 저장해두고, 다음번에는 계산 없이 바로 가져다 쓰는 기술. 하지만 솔라가 아는 캐싱은 웹서버 설정 파일을 만지거나, 레디스(Redis) 같은 별도의 메모리 데이터베이스를 설치하고 연동해야 하는 복잡하고 어려운 작업이었다. 프론트엔드 없이 대시보드를 만든다는 기쁨도 잠시, 결국 ‘서버 전문가’의 영역에 발을 들여야 하는 것 같아 눈앞이 캄캄해졌다.

“캐싱을 해야 할 것 같은데… 그거 완전 서버 설정의 영역 아니야? 스트림릿의 간편함은 여기까지가 끝인가 봐.”

그녀의 푸념에 루나는 고개를 저었다. 루나는 솔라의 코드를 가리키며 말했다.

“서버 전체를 생각할 필요 없어. 문제의 핵심만 보는 거야. 매번 다시 실행될 때, 변하지 않아도 되는 부분은 어디지?”

load_data() 함수가 반환하는 결과. 데이터프레임 자체는 한번 불러오면 바뀔 필요가 없잖아.”

“바로 그거야. 그렇다면 스트림릿에게 이렇게 말해주면 되지 않을까? ‘이 함수는 입력값이 바뀌지 않는 한, 딱 한 번만 실행하고 그 결과를 기억해 줘’라고.”

‘함수 결과를 기억해달라’는 말. 솔라는 마치 힌트를 얻은 탐정처럼 눈을 반짝였다. 그녀는 서버 설정 파일이 아니라, 파이썬 코드 안에서 해결책을 찾기 시작했다. 검색창에 ‘streamlit remember function result’라고 입력하자마자, 마법 같은 단어가 눈에 들어왔다. 데코레이터(Decorator)였다.

솔라는 load_data 함수 위쪽에 딱 한 줄의 코드를 추가했다.

@st.cache_data
def load_data():
    print("데이터 로딩 중...")
    time.sleep(3)
    ...

@st.cache_data. ‘데이터를 캐시하라’는 의미가 명확하게 와닿는 이름이었다. 그녀는 반신반의하며 파일을 저장했다.

첫 로딩은 여전히 3초가 걸렸다. 터미널에는 ‘데이터 로딩 중…’ 메시지가 찍혔다. 하지만 진짜 시험은 지금부터였다. 솔라는 떨리는 손으로 필터의 ‘코드 에이전트’를 다시 껐다.

즉시. 화면이 바뀌었다. ‘Running…’ 아이콘은 눈 깜짝할 사이에 사라졌고, 3초의 기다림은 없었다. 터미널에는 아무 메시지도 찍히지 않았다. load_data 함수가 다시 호출되지 않았다는 명백한 증거였다. 솔라는 다른 에이전트들도 뺐다 넣었다 반복했다. 결과는 마찬가지였다. 모든 변경사항이 즉각적으로 반영되었다.

“됐다! 됐어! 언니, 이거 봐! 처음 한 번만 로딩하고 그 뒤로는 전혀 안 느려져!”

솔라는 서버 설정 파일 하나 건드리지 않고, 오직 파이썬 데코레이터 한 줄로 고질적인 성능 문제를 해결했다. 스트림릿은 복잡한 캐싱 로직을 @st.cache_data라는 간단한 마법 주문 뒤에 숨겨두었던 것이다.

성능 문제에서 해방된 솔라는 이제 대시보드의 ‘모양새’에 신경 쓰기 시작했다. 필터, 표, 그래프가 모두 세로로 길게 늘어져 있어 한눈에 들어오지 않았다.

“음, 이제 빠르긴 한데… 좀 못생겼어. 이 표랑 그래프를 나란히 옆에 두고 싶은데. 이러려면 또 CSS 써서 레이아웃 잡아야 하는 거 아니야?”

솔라의 이죽거림에 루나는 웃으며 답했다.

“옆으로 놓는 건 ‘열’이나 ‘칸’을 나누는 거지? 스트림릿에 그런 단어가 있는지 찾아봐.”

솔라는 ‘streamlit column’을 검색했고, 곧바로 st.columns를 찾아냈다. 사용법은 with 구문을 쓰는 파이썬다운 방식이었다.

col1, col2 = st.columns(2)

with col1:
    st.write("에이전트별 성능 지표")
    st.write(filtered_df)

with col2:
    st.pyplot(fig)

파일을 저장하자, 세로로 길게 늘어져 있던 표와 그래프가 두 개의 깔끔한 열로 나뉘어 양옆에 자리 잡았다. 여기에 더해, st.expander를 사용해 거대한 데이터 표를 평소에는 접어두고 필요할 때만 펼쳐볼 수 있게 만들었다.

with st.expander("데이터 표 보기"):
    st.write(filtered_df)

몇 줄의 파이썬 코드만으로 대시보드는 빠르고, 보기 좋고, 심지어 사용자가 원하는 정보만 펼쳐볼 수 있는 똑똑한 모습으로 변해있었다.

솔라는 최종 완성된 대시보드를 보며 뿌듯함을 감추지 못했다. 자신의 localhost:8501 화면 속에서 완벽하게 작동하고 있었다. 그런데 문득, 이 완벽한 결과물을 혼자만 보고 있다는 사실이 아쉬워졌다.

“언니, 이제 대시보드는 다 만든 것 같아. 빠르고, 예쁘고, 상호작용도 돼. 그런데… 이건 내 컴퓨터에서만 볼 수 있잖아. 이걸 우리 팀원들이나 다른 사람들에게 보여주려면 어떻게 해야 하지? 이 주소를 그대로 보내줄 수는 없을 텐데.”

5장: Cloudflare Tunnel로 외부 공개하기: 전 세계와 연결된 대시보드

솔라는 자신의 노트북 화면을 보며 만족스러운 미소를 지었다. localhost:8501. 자신의 컴퓨터에서만 존재하는 이 주소 안에는, 이제 제법 그럴듯한 ‘에이전트 운영 결과 대시보드’가 완벽하게 작동하고 있었다. 필터를 조작해도 3초의 지연 없이 즉각 반응했고, 표와 그래프는 깔끔한 열로 정돈되어 있었다. 파이썬 코드만으로, 프론트엔드나 서버 지식 없이 이 모든 것을 해냈다는 사실이 뿌듯했다.

‘이걸 빨리 팀원들에게 보여주고 싶다.’

솔라는 의기양양하게 스마트폰을 집어 들었다. 팀 메신저 앱을 열어 새로운 메시지를 작성했다. “여러분, 제가 만든 대시보드 프로토타입 한번 보세요!” 그리고 망설임 없이 노트북 화면에 보이는 주소를 그대로 입력했다. http://localhost:8501. 전송 버튼을 누르려는 찰나, 그녀의 손가락이 허공에서 멈췄다.

‘아차.’

localhost. ‘이 컴퓨터’라는 뜻의 주소. 이 주소는 오직 자신의 노트북에서만 유효했다. 다른 사람의 컴퓨터나 스마트폰에서 이 주소를 입력해봤자 ‘연결할 수 없음’이라는 차가운 메시지만 보일 터였다. 방금 전까지 완벽하다고 생각했던 대시보드는, 사실상 인터넷과 단절된 자신만의 섬 안에 갇혀 있는 셈이었다. 솔라는 김이 빠진 듯 스마트폰을 내려놓았다.

“언니, 이제 대시보드는 다 만든 것 같은데… 이걸 다른 사람들에게 보여줄 수가 없어.”

솔라의 목소리에는 깊은 실망감이 묻어났다.

“이 localhost 주소는 내 컴퓨터에서만 되는 거니까. 결국 이걸 제대로 공유하려면, AWS 같은 클라우드 서버를 빌려서 코드를 올리고, 웹서버 프로그램을 설치하고, 방화벽 포트도 열어줘야 하는 거잖아. 배보다 배꼽이 더 큰 것 같아. 간단하게 만들 수 있다고 좋아했는데, 배포는 결국 똑같이 복잡하네.”

그녀의 머릿속에는 ‘배포’라는 단어와 함께 따라오는 수많은 복잡한 절차들이 스쳐 지나갔다. 도메인 구입, SSL 인증서 발급, 서버 보안 설정… 파이썬으로 쉽게 쌓아 올린 탑을 옮기기 위해 거대한 크레인을 동원해야 하는 기분이었다. 스트림릿이 해결해준 것은 딱 거기까지, ‘만드는 과정’뿐이라고 느껴졌다.

루나는 복잡한 서버 아키텍처 다이어그램 대신, 솔라의 노트북과 그 옆에 놓인 스마트폰을 번갈아 가리켰다.

“문제의 본질은 간단해. 지금 솔라 네 노트북에서 돌아가는 streamlit 웹서버를, 외부 인터넷에 연결된 저 스마트폰이 찾아오지 못한다는 거야. 그렇다면 네 노트북과 외부 인터넷 사이에 임시로 길을 하나 터주면 되지 않을까? 외부에서 들어오는 요청을 네 localhost:8501로 안전하게 연결해주는 ‘터널’ 같은 거 말이야.”

‘터널’이라는 단어에 솔라의 귀가 쫑긋했다. 서버에 코드를 ‘옮겨 심는’ 복잡한 배포가 아니라, 지금 내 컴퓨터에서 실행되는 것을 그대로 ‘연결’해준다는 발상은 훨씬 가볍게 느껴졌다.

“내 컴퓨터로 터널을 뚫는다고? 그런 것도 가능해?”

“그런 용도로 만들어진 도구들이 있어. 로컬 웹서버를 위한 임시 공개 URL을 만들어주는 도구지.”

루나의 힌트에 솔라는 ‘local web server public url’이라는 키워드로 검색을 시작했다. 그리고 곧, 스트림릿 공식 문서나 여러 개발자 블로그에서 공통으로 언급되는 이름 하나를 발견했다. Cloudflare Tunnel. 설명은 루나의 말과 정확히 일치했다. ‘로컬에서 실행 중인 웹 서버를 외부에서 접속할 수 있도록 임시 공개 URL을 만들어 주는 도구.’

솔라는 가이드에 따라 터미널에 몇 가지 명령어를 입력해 도구를 설치했다. 과정은 복잡하지 않았다. 그리고 마지막으로, 현재 스트림릿 앱이 실행되고 있는 http://localhost:8501 주소를 외부로 연결해달라는 명령을 입력했다.

cloudflared tunnel --url http://localhost:8501

엔터 키를 누르자, 터미널에 몇 줄의 로그가 출력되더니 곧 이상한 주소 하나가 나타났다.

https://some-random-words.trycloudflare.com

localhost도 아니고, 익숙한 웹사이트 주소도 아닌, 무작위 단어들로 조합된 낯선 URL이었다. 터미널은 이 주소로 들어오는 모든 요청을 http://localhost:8501로 전달하기 시작했다고 알려주고 있었다.

“이게… 된다고?”

솔라는 반신반의하며 다시 스마트폰을 집어 들었다. 브라우저를 열고, 터미널에 나타난 그 길고 낯선 주소를 한 글자 한 글자 조심스럽게 입력했다. 로딩 아이콘이 잠시 빙글빙글 돌았다. 솔라는 마른침을 삼켰다.

순간, 스마트폰 화면에 익숙한 대시보드가 나타났다. ‘에이전트 운영 결과 대시보드’라는 제목, 에이전트를 선택하는 필터, 깔끔하게 정돈된 표와 그래프까지. 자신의 노트북 localhost 화면과 모든 것이 똑같았다. 심지어 스마트폰 화면에서 필터를 조작하자, 노트북에서와 마찬가지로 데이터와 그래프가 즉각적으로 반응했다.

“와…! 된다! 진짜 되네!”

솔라는 자신의 스마트폰 화면과 노트북 화면을 번갈아 보며 감탄사를 연발했다. 자신의 노트북이, 바로 이 순간 진짜 웹서버가 되어 전 세계 어디서든 접속할 수 있는 사이트를 운영하고 있는 것이나 마찬가지였다. 복잡한 서버 배포, 방화벽 설정, 도메인 연결 같은 절차는 하나도 없었다. 명령어 단 한 줄이 모든 것을 해결해주었다.

그녀는 자신이 가졌던 ‘배포는 복잡하다’는 고정관념이 얼마나 낡은 것이었는지 깨달았다. 물론 실제 서비스를 운영하려면 여전히 전통적인 배포 방식이 필요하겠지만, 팀원들과 빠르게 결과물을 공유하고 피드백을 받는 목적만이라면 이 ‘외부 공개 터널’이야말로 가장 완벽한 해답이었다.

솔라는 다시 팀 메신저 앱을 열었다. 아까 쓰다 말았던 localhost 주소를 깨끗하게 지웠다. 그리고 방금 스마트폰에서 접속에 성공한, 마법 같은 trycloudflare.com 주소를 붙여넣었다.

“여러분, 죄송해요! 이걸로 다시 봐주세요. 제 노트북에서 지금 막 띄운 대시보드인데, 바로 보일 거예요. 한번 테스트해보시고 의견 주세요!”

망설임 없이 전송 버튼을 누르는 솔라의 얼굴에, 처음 파이썬 코드만으로 UI를 띄웠을 때보다 더 큰 자신감과 만족감이 어렸다. 이제 그녀는 단순히 파이썬으로 대시보드를 ‘만드는’ 것을 넘어, 그것을 세상과 ‘공유하는’ 가장 빠르고 쉬운 방법까지 손에 넣은 것이다.