Streamlit 핵심 (1.58.x) — 실행 모델, 상태, 새로고침은(는) 무엇을 하나요?
하나의 핵심 개념: 모든 사용자 상호작용(위젯 조작)마다 Streamlit은 전체 스크립트를 처음부터 다시 실행합니다. 이벤트 핸들러, 영속적인 지역 변수, 메인 루프는 없습니다. 각 실행은 st.session_state + 현재 위젯 값들의 순수 함수입니다. 아래의 모든 내용은 이 개념에서 비롯됩니다.
모든 주장을 직접 실행해볼 수 있는 증명 파일: 이 스킬 폴더의 st_core_lab.py
(python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py)
Streamlit 1.58에서 검증되었으며, 1.55 API에서도 확인 완료: 이 스킬의 모든 내용은 두 버전에서 사용 가능합니다 (st.fragment(run_every=)는 ≥1.37 필요).
엄격한 규칙 (위반 시 전형적인 문제 앱 발생)
- 스크립트 레벨에서 절대
while True:를 사용하지 마십시오. 스크립트는 각 실행이 종료되어야 합니다. 라이브 섹션을 위해서는@st.fragment(run_every="2s")를 사용하세요(아래 참조). 루프는 단일 실행 내에서 유한한 작업(진행률 표시줄,st.write_stream)에만 사용할 수 있습니다. - 절대 조건 없이
st.rerun()을 호출하지 마십시오 (또는 다음 실행에도 여전히 참인 조건 하에서) — Streamlit에는 루프 방지 기능이 없습니다. 영원히 회전합니다. 모든st.rerun()은 먼저 플립하는 상태 가드 뒤에 위치해야 합니다:if st.session_state.get("needs_refresh"): st.session_state.needs_refresh = False # rerun 전에 플립 refresh_data() st.rerun() - 자동 새로고침을 위해
time.sleep(n); st.rerun()을 절대 사용하지 마십시오. 페이지가 안정되지 않습니다: 위젯이 깜빡이고, 클릭이 수면 중에 무시됩니다.@st.fragment(run_every=...)를 사용하거나, "새로고침"이 단순히 최신 데이터를 의미한다면st.cache_data(ttl=...)를 사용하세요. 기본st.autorefresh는 없습니다 (확인 완료 —dir(st)에 없음).streamlit-autorefreshPyPI 패키지는 서드파티이며 매 틱마다 전체 스크립트를 다시 실행합니다(모든 위젯 리셋, 포커스 탈취); 네이티브 스코프 제한@st.fragment(run_every=...)를 선호하세요. st.experimental_rerun()은 존재하지 않습니다 (1.27+에서 제거됨).st.rerun()을 사용하세요.st.experimental_memo/singleton도 마찬가지로 →st.cache_data/st.cache_resource;st.experimental_fragment→st.fragment;@st.cache도 사라졌습니다.- 대부분의
st.rerun()호출은 불필요합니다. 위젯 상호작용은 이미 rerun을 트리거하며,on_click/on_change콜백은 해당 rerun 전에 실행되므로, 추가 rerun 없이도 상태 쓰기가 표시됩니다. 합법적인 사용 예:st.dialog닫기, 렌더링된 위치 아래에서 이루어진 프로그램적 상태 변경 적용(규칙 2 패턴).
st.button은 순간적입니다 — 출력 사라짐 버그의 원인 #1
st.button()은 정확히 한 번의 실행(클릭) 동안만 True를 반환하고, 그 후에는 다시 False가 됩니다. 따라서 if st.button("run"): st.write(result)는 사용자가 다른 것을 건드리기 전까지 결과를 보여주지만, 다른 조작 시 결과는 사라집니다. 또한 중첩된 버튼은 동시에 둘 다 참일 수 없습니다: 내부 버튼을 클릭하면 외부 버튼은 다시 False가 됩니다(확인 완료).
# 잘못된 예 # 올바른 예 — 상태를 통해 유지
if st.button("계산"): if st.button("계산"):
r = model() st.session_state.r = model()
st.write(r) # 다음 실행 시 사라짐 if "r" in st.session_state:
st.write(st.session_state.r)
다단계 흐름: session_state에 하나의 step 키를 사용하고 규칙 2에 따라 st.rerun()을 사용하며, 버튼 안에 버튼을 절대 넣지 마십시오.
session_state 규칙
- 한 번만 초기화:
st.session_state.setdefault("k", default)(또는if "k" not in st.session_state:형식). 위젯보다 먼저, 최상단에서 수행하십시오. - 이번 실행에서
key="k"인 위젯이 생성된 후에st.session_state.k를 쓰면StreamlitAPIException: cannot be modified오류가 발생합니다. 위젯을 프로그래밍 방식으로 설정하려면 위젯 줄 전에 키를 쓰십시오 (보통: 플래그 설정 +st.rerun(), 다음 실행 시작 부분에서 쓰기 적용) — 또는 콜백에서 수행하십시오. 콜백은 항상 안전합니다. key가 이미 session_state에 있으면value=는 무시됩니다 — 상태가 우선합니다. 유일한 흔적은 서버 로그의 경고("created with a default value but also had its value set via the Session State API")이며 UI에는 아무것도 나타나지 않습니다. 둘 다 전달하고 기본값이 적용될 것이라고 기대하지 마십시오.- 실행 시 위젯 라인이 실행되지 않으면 해당 위젯의 상태는 사라집니다 (조건부로 숨겨진 탭, 접힌 분기 — 확인 완료: 슬라이더가 8이었는데 0으로 돌아옴). 숨김을 견디려면 고유 키에 미러링하십시오:
st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w))그리고 위젯 전 쓰기 패턴을 통해 다시 공급하십시오. (st.tabs는 안전합니다 — 모든 탭의 코드는 숨겨지든 말든 매번 실행됩니다. 이것이 무거운 탭에 캐싱이 필요한 이유이기도 합니다.) - 동일한 유형+매개변수를 가진 두 위젯은
StreamlitDuplicateElementId오류를 발생시킵니다 — 구별되는key=를 전달하십시오. 루프 내 위젯:key=f"row-{i}".
콜백이 수동 rerun보다 낫습니다
렌더링 지점 아래에서 변경된 상태는 한 번의 실행 동안 오래된 값으로 읽힙니다(확인 완료: if st.button(...) 분기는 그 위에 그려진 카운터 이후에 실행됩니다). 콜백은 순서를 수정합니다 — 스크립트가 다시 실행되기 전에 실행됩니다:
st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc) # count는 같은 실행에서 신선함
st.metric("count", st.session_state.count)
on_change도 입력/선택 위젯에서 동일하게 작동합니다. 콜백은 어떤 상태 키든 쓸 수 있습니다 (위젯의 키도 가능, 해당 라인이 아직 실행되지 않았으므로). st.rerun()이 필요 없습니다.
- 콜백 내부의
st.rerun()은 NO-OP입니다 (확인 완료): 사용자에게 보이는 경고Calling st.rerun() within a callback is a no-op.를 표시하고 콜백 본문의 나머지를 중단합니다 — 추가 rerun 없음, 예외 없음. 클릭은 이미 rerun을 예약했습니다. 콜백에서는session_state를 변경하고 반환하기만 하면 됩니다. "변경 사항을 적용"하기 위해st.rerun()을 추가하면 콜백이 조용히 잘립니다.
폼 — 키 입력마다 rerun 방지
모든 위젯 상호작용은 스크립트를 다시 실행합니다. N개의 입력 = 사용자가 입력하는 동안 N번의 rerun (각각 데이터 레이어에 다시 접근). 배치 처리:
with st.form("params"):
a = st.slider("a", 0, 10)
b = st.selectbox("b", opts)
go = st.form_submit_button("적용")
if go: run_query(a, b)
제출 전까지 아무것도 rerun되지 않습니다 (확인 완료: 실행 카운터 총 +1). 폼 내부: st.button 없음, 제출 버튼 외 위젯별 콜백 없음.
"입력 중에 rerun이 발생합니다"는 거의 항상 키 입력 때문이 아닙니다. 위젯은 다양한 이벤트에서 커밋됩니다(Streamlit 프론트엔드 동작): text_input/text_area/number_input은 Enter 또는 포커스 해제 시, slider/select_slider는 놓을 때 실행됩니다 — 키 입력마다, 드래그 중간에 실행되지 않습니다. checkbox/radio/selectbox/toggle은 즉시 실행됩니다. 한 번의 커밋된 변경 = 한 번의 rerun이며, 값이 변경되지 않으면 프론트엔드는 아무것도 보내지 않습니다. 따라서 입력 중 지속적인 rerun은 동시에 실행되는 전체 앱 자동 새로고침(타이머 / st_autorefresh / sleep+rerun)이 포커스를 빼앗고 있음을 의미합니다 — 해당 새로고침을 입력을 포함하지 않는 @st.fragment로 범위를 제한하거나, 입력을 st.form으로 감싸십시오. 키 입력을 디바운스할 필요가 없습니다. Streamlit은 그것이 필요하지 않습니다.
캐싱 — 대부분의 "잘못된 새로고침"은 사실 이것입니다
@st.cache_data— 데이터프레임/직렬화 가능한 결과. 호출할 때마다 새로운 복사본을 반환합니다 (변경 안전,is가 False임 확인). TTL: 데이터 새로고침이 필요한 경우ttl="15m". ↻ 버튼에Foo.clear()또는st.cache_data.clear()사용.@st.cache_resource— 연결, 모델, 스레드. 모든 세션에 동일한 객체 반환 (확인 완료) — 사용자별로 절대 변경하지 말고, 필요하면 락을 추가하십시오.- 인자는 해시 가능해야 하며 캐시 키를 형성합니다. 해시 불가능한 인자는 앞에
_를 붙여 제외하십시오 (def load(_conn, day):). - 캐싱 없이, 모든 위젯 틱은 스크립트의 모든 로드를 다시 실행합니다 — 각 클릭에 몇 초가 소요되어 앱이 "멈춘 것처럼" 느껴집니다. 먼저 캐싱하고, 그 다음 rerun을 디버깅하십시오.
올바른 라이브 새로고침 — st.fragment
@st.fragment(run_every="2s") # int 초 / timedelta / "2s" 문자열
def ticker():
st.metric("마지막", get_price()) # 이 함수만 다시 실행됨
ticker()
- 페이지의 나머지 부분은 rerun되지 않습니다 (확인 완료: 외부 실행 카운터는 프래그먼트가 틱할 때 멈춤). 프래그먼트 내부의 위젯은 프래그먼트만 다시 실행합니다.
st.rerun(scope="fragment")는 프래그먼트만 다시 시작합니다. - 주의 (확인 완료):
scope="fragment"는 프래그먼트 본문이 전체 앱 실행(첫 번째 페인트)의 일부로 실행 중일 때 오류를 발생시킵니다 — 이를 방어하거나, 상태를 플립하고run_every가 이를 처리하도록 하십시오. - 프래그먼트는
session_state를 자유롭게 읽고 쓸 수 있습니다. 변경 사항을 전체 페이지로 내보내려면 상태를 쓴 다음st.rerun()을 호출하십시오 (기본값인scope="app"). - OUTER 스크립트에서
run_every프래그먼트를 중지하거나 시간을 재설정하십시오 — 내부의 위젯으로는 할 수 없습니다.run_every는 전체 앱 실행에서만 다시 읽히므로, 프래그먼트 내부의 간격 슬라이더나 중지 버튼(프래그먼트만 다시 실행)은 타이머를 다시 설정하지 않습니다 (확인 완료). 외부에서 프래그먼트 호출을 제어하십시오:전체 rerun 시,secs = st.slider("간격", 1, 10, 2) # 프래그먼트 외부 run = st.session_state.get("run", True) tick = st.fragment(run_every=secs if run else None)(_body) tick() if st.button("중지"): st.session_state.run = False; st.rerun()run_every=None(또는 호출하지 않음)은 자동 새로고침 메시지를 내보내지 않으며 타이머가 중지됩니다. - 프래그먼트의 반환 값은 전체 실행에서만 호출자에게 도달합니다 (확인 완료).
run_every틱 또는 프래그먼트 내부 위젯 변경 시 외부 스크립트는 다시 실행되지 않으므로,data = ticker()는 오래된 값을 유지하고 다운스트림 차트는 절대 업데이트되지 않습니다. 라이브 데이터는 프래그먼트 내부에서 작성된st.session_state를 통해 내보내십시오 (st.session_state.data = fetch()), 외부에서 읽거나 — 차트를 프래그먼트 자체 내에 그리십시오. 프래그먼트를 틱 간에 할당할 수 있는 일반 함수로 취급하지 마십시오. - 프래그먼트는 앱의 단일 스레드에서 실행됩니다 — 백그라운드 스레드가 아닙니다. 내부에서
time.sleep을 사용하면 해당 기간 동안 전체 페이지가 멈춥니다;run_every가 주기가 되게 하고, 절대 sleep을 사용하지 마십시오. 중첩 프래그먼트는 허용되지만(확인 완료) 각각 독립적인 rerun 범위입니다 — 하위 틱은 상위를 다시 실행하지 않으므로, 데이터를 소유한 프래그먼트에run_every를 유지하고 동일한 데이터에 대해 타이머를 중첩하지 마십시오. 실제 백그라운드 작업(느린 작업 제출, 계속 탐색, 결과 도착)의 경우 → [st-jobs] 참조. - 동일한 키를 가진 위젯을 프래그먼트 내부와 외부 모두에 생성하지 마십시오.
- 프래그먼트가 틱하지만 값이 절대 변하지 않나요? 라이브 읽기가 캐시되었습니다.
ttl이 없는@st.cache_data로더는 본문을 한 번 실행하고(확인 완료) 모든 틱에서 해당 고정된 값을 반환합니다 —run_every는 함수를 다시 실행하지만 캐시가 이를 단락시킵니다. 이것이 "내 프래그먼트가 새로고침되지 않음"의 원인 #1입니다. 해결 방법: 라이브 읽기를 캐시하지 않거나, 새로고침 주기보다 작거나 같은ttl을 지정하거나(@st.cache_data(ttl="2s")), 매 틱마다 인자를 변경하십시오.
스코프 제한 작업 상자로서의 프래그먼트 — 지금 가져오고, 나중에 사용 (전체 rerun 없음)
run_every 없는 프래그먼트는 페이지 내 상자로, 위젯이 상자만 다시 실행합니다 — 전체 페이지를 조회당 다시 실행하지 않고 "여기서 참조 데이터를 조회하고, 나중에 작업에서 소비"하는 패턴입니다:
@st.fragment
def fetch_box(): # 여기서의 클릭은 상자만 다시 실행
with st.form("조회"): # 프래그먼트 내 폼: 허용됨 (확인 완료)
code = st.selectbox("엔터티", codes, key="box_code")
go = st.form_submit_button("세부 정보 가져오기")
if go:
st.session_state.details = get_details(code) # STATE를 통해 전달
st.success(f"{code} 가져옴")
fetch_box()
if st.button("요청 실행"): # 외부 → 클릭 자체가 전체 rerun이며,
d = st.session_state.get("details") # 저장된 세부 정보를 읽음
if d is None: st.warning("먼저 가져오세요")
else: run_request(d)
- 소비자는
st.rerun()이 필요 없습니다: 자체 클릭이 새로운 상태를 읽는 전체 실행이기 때문입니다(확인 완료). 외부 페이지가 가져온 데이터를 즉시(다음 클릭 전에) 표시해야 하는 경우에만 프래그먼트 내부에서 상태를 쓴 후st.rerun()을 수행하십시오 — 전체 실행 한 번이 소요됩니다. - 프래그먼트의 반환 값(상자 전용 rerun 간에 오래됨 — 위 글머리 기호 참조)이 아닌
session_state를 통해 전달하십시오.@st.cache_data를 사용하여 동일한 엔터티를 다시 가져오는 것을 무료로 만드십시오.
작업 중 상태 표시 (단일 실행 내 — 허용된 루프)
ph = st.empty() # 플레이스홀더, 제자리 덮어쓰기
for i, step in enumerate(steps):
ph.text(f"{i+1}/{len(steps)} {step.name}")
step.run()
ph.empty()
또한 st.progress, st.status("...", expanded=True), st.spinner, st.toast도 사용 가능합니다. 이들은 단일 실행 내에서 애니메이션됩니다 — rerun 없음, 루프 위험 없음.
디버그 체크리스트: "루프에 빠짐 / 계속 새로고침됨"
st.rerun을 검색 — 모든 호출이 먼저 플립하는 상태 가드 뒤에 있습니까 (규칙 2)?- 스크립트 레벨에서
while True/time.sleep을 검색 → 프래그먼트로 대체 (규칙 1/3). - 한 위젯이 다른 라인에서 다시 쓰는 상태 키를 쓰는 경우 → 핑퐁 rerun; 키당 하나의 작성자 유지.
key=충돌 또는 매 실행마다 변경되는 f-문자열 키(예:time.time()포함) — 매 실행 새로운 키 = 매 실행 위젯 리셋.- 캐시되지 않은 느린 로드는 정상적인 rerun을 루프처럼 보이게 만듭니다 —
st.cache_data와 실행 카운터(st.session_state.runs += 1)를 추가하여 실제 상황을 확인하십시오. - 라이브 섹션이 타이머에 따라 새로고침되지만 오래된 값을 표시하는 경우 →
ttl이 없는@st.cache_data함수가 이를 공급합니다. 프래그먼트는 다시 실행되지만 캐시가 이전 값을 반환합니다.ttl을 추가하거나 라이브 읽기를 캐시하지 마십시오.