글자 크기

수순 복기 기능을 만들며 겪은 Streamlit의 함정들

단순한 요구사항

300수짜리 기보가 있다.
1수부터 300수까지 넘겨보고 싶다.

필요한 건 세 가지.

  1. 슬라이더 (드래그로 이동)
  2. 버튼 (◀ ▶ 하나씩 이동)
  3. 키보드 단축키 (방향키로 이동)

간단해 보였다.
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은 위젯 상태 관리에 엄격하다.
직접 제어하려 하면 막는다.


해결책: 상태를 분리하고, 삭제하고, 다시 만들기

결국 찾은 방법.

  1. 내부 상태 (replay_move)와 위젯 상태 (replay_slider_widget)를 분리
  2. 버튼을 누르면 내부 상태 변경 + 위젯 상태 삭제
  3. 페이지가 다시 그려지면 위젯이 내부 상태 기준으로 새로 생성
# 버튼 클릭
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 개발기는 계속됩니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다