단순한 요구사항
300수짜리 기보가 있다.
1수부터 300수까지 넘겨보고 싶다.
필요한 건 세 가지.
- 슬라이더 (드래그로 이동)
- 버튼 (◀ ▶ 하나씩 이동)
- 키보드 단축키 (방향키로 이동)
간단해 보였다.
Streamlit이면 금방 만들겠지.
move = st.slider("수순", 0, 300, value=0)
슬라이더 완성.
5분 걸렸다.
그리고 지옥이 시작됐다.
첫 번째 함정: 버튼이 슬라이더를 못 바꾼다
“다음 수” 버튼을 만들었다.
if st.button("▶"):
st.session_state.move += 1
눌렀다.
슬라이더가 안 움직인다.
왜?
Streamlit의 위젯은 자기만의 상태를 가진다.
st.slider에 key를 주면,
그 키로 session_state에 값이 저장된다.
st.slider("수순", 0, 300, key="move_slider")
# st.session_state.move_slider 에 값이 저장됨
그런데 이 값을 외부에서 바꾸면?
StreamlitAPIException: st.session_state.move_slider cannot be modified after the widget is instantiated.
위젯이 생성된 후에는 그 상태를 건드리지 말라는 뜻이다.
두 번째 함정: value와 key를 같이 쓰면 안 된다
그럼 이렇게 하면 되지 않을까?
st.slider("수순", 0, 300, value=st.session_state.move, key="move_slider")
value로 초기값을 주고, key로 상태를 관리하고.
에러.
The widget with key "move_slider" was created with a default value but also had its value set via the Session State API.
둘 중 하나만 쓰라는 뜻이다.
Streamlit은 위젯 상태 관리에 엄격하다.
직접 제어하려 하면 막는다.
해결책: 상태를 분리하고, 삭제하고, 다시 만들기
결국 찾은 방법.
- 내부 상태 (
replay_move)와 위젯 상태 (replay_slider_widget)를 분리 - 버튼을 누르면 내부 상태 변경 + 위젯 상태 삭제
- 페이지가 다시 그려지면 위젯이 내부 상태 기준으로 새로 생성
# 버튼 클릭
if st.button("▶"):
st.session_state.replay_move += 1
del st.session_state["replay_slider_widget"] # 삭제!
st.rerun()
# 슬라이더 (삭제됐으면 새로 만들어짐)
if "replay_slider_widget" not in st.session_state:
st.session_state.replay_slider_widget = st.session_state.replay_move
st.slider("수순", 0, 300, key="replay_slider_widget", on_change=sync_callback)
지저분하다.
하지만 된다.
세 번째 함정: 슬라이더 변경이 버튼에 반영 안 됨
이번엔 반대 문제.
슬라이더를 드래그해서 100수로 이동.
그런데 내부 상태 replay_move는 그대로 0.
on_change 콜백을 써야 했다.
def on_slider_change():
st.session_state.replay_move = st.session_state.replay_slider_widget
st.slider(..., on_change=on_slider_change)
슬라이더가 바뀔 때마다 내부 상태를 동기화.
이제야 버튼과 슬라이더가 같은 값을 본다.
네 번째 도전: 키보드 단축키
버튼은 됐다.
그런데 300수를 버튼으로 넘기는 건 손가락이 아프다.
키보드 방향키로 넘기고 싶었다.
Streamlit에는 키보드 이벤트 기능이 없다.
없다.
공식적으로 지원하지 않는다.
JavaScript 주입
방법은 하나.
JavaScript를 직접 넣는 것.
import streamlit.components.v1 as components
components.html("""
<script>
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowRight') {
const url = new URL(window.parent.location);
url.searchParams.set('key', 'next');
window.parent.location.href = url.toString();
}
});
</script>
""", height=0)
키를 누르면 URL에 파라미터를 붙여서 페이지를 새로고침.
Streamlit이 파라미터를 읽고 상태를 변경.
key_action = st.query_params.get("key")
if key_action == "next":
st.query_params.clear()
st.session_state.replay_move += 1
st.rerun()
우아하지 않다.
URL이 깜빡인다.
하지만 된다.
Streamlit의 철학
이쯤 되면 의문이 든다.
왜 이렇게 불편하게 만들어놨을까?
Streamlit은 “스크립트를 위에서 아래로 실행”하는 모델이다.
상태가 바뀌면 전체를 다시 실행한다.
이게 장점이기도 하다.
복잡한 상태 관리 없이 데이터 앱을 빠르게 만들 수 있다.
하지만 인터랙티브한 앱을 만들려면,
이 모델과 싸워야 한다.
결국 완성된 것
| 입력 | 동작 |
|---|---|
| 슬라이더 드래그 | 원하는 수로 점프 |
| ◀ 버튼 | 이전 수 |
| ▶ 버튼 | 다음 수 |
| ← 방향키 | 이전 수 |
| → 방향키 | 다음 수 |
| Home | 처음으로 |
| End | 마지막으로 |
300수를 자유롭게 오갈 수 있다.
방향키를 연타하면 빠르게 훑을 수 있다.
배운 것
Streamlit은 프로토타이핑 도구다.
빠르게 뭔가를 보여주기엔 최고다.
하지만 세밀한 제어가 필요하면 한계가 온다.
- 위젯 상태는 Streamlit이 관리한다. 뺏으려 하지 마라.
- 키보드 이벤트? 직접 JS를 넣어라.
- 상태 동기화? 콜백과 삭제를 조합해라.
프레임워크와 싸우지 말고, 프레임워크의 방식을 따라라.
그게 이번에 배운 교훈이다.
다음은?
기능은 됐다.
KataGo 분석, 추천수 시각화, 수순 복기.
그런데 여전히 부족한 게 있다.
AI는 “여기가 좋다”고 한다.
왜 좋은지는 모른다.
추천수 A를 봐도,
그게 왜 최선인지 이해하려면 내 실력이 따라줘야 한다.
다음 글에서는 이 한계에 대해,
그리고 앞으로 어떻게 풀어갈지 이야기해보려 한다.
KaiGo Coach 개발기는 계속됩니다.
