“◀2수” 버튼을 눌렀다. 잘 된다. 한 번 더 눌렀다. 반응이 없다. 아니, 반응은 있는데 다시 원래대로 돌아온다. 두 수 물렸다가 두 수가 다시 놓아진다. 무한루프처럼.
Streamlit 버튼이 이상하게 동작할 때, 대부분은 내 잘못이다. 이번에도 그랬다.
증상: 두 수 사이를 반복
물리기 버튼의 동작은 단순하다.
if st.button("◀2수"):
undo_moves() # 2수 취소
st.rerun()
undo_moves()는 KataGo 엔진에 undo 명령을 두 번 보내고, 보드 상태를 동기화한다. 한 번은 잘 된다.
문제는 두 번째 클릭이다. 버튼을 누르면 2수가 취소되고, 다시 2수가 놓아진다. 계속 같은 위치를 왔다갔다한다.
원인: plotly_events의 잔상
바둑판은 Plotly로 그린다. 클릭 이벤트는 streamlit-plotly-events 라이브러리로 받는다.
clicked = plotly_events(fig, click_event=True)
if clicked:
point = clicked[0]
# 클릭한 좌표에 착수
play_move(point["x"], point["y"])
문제는 plotly_events가 반환하는 클릭 데이터가 “잔상”처럼 남는다는 것이다.
- 사용자가 Q16에 착수 (클릭)
- AI가 응수
- 사용자가 “◀2수” 클릭
- st.rerun() 발생
- plotly_events가 이전 Q16 클릭을 다시 반환
- Q16에 다시 착수됨
- AI가 다시 응수
- 원점으로 돌아옴
rerun() 후에도 plotly 컴포넌트가 이전 클릭 데이터를 기억하고 있었다.
시도 1: 플래그로 무시하기
첫 번째 시도는 단순했다. 물리기 후 다음 클릭을 무시하는 플래그를 세운다.
def undo_moves():
# ... 2수 취소 로직 ...
st.session_state.skip_next_click = True
return True
# 클릭 처리부
if st.session_state.get("skip_next_click"):
st.session_state.skip_next_click = False
clicked = None # 무시
첫 번째 물리기는 해결됐다. 하지만 두 번째 물리기에서 같은 문제가 발생했다.
시도 2: 버튼에 동적 키
Streamlit 버튼은 같은 key를 가지면 상태가 유지된다. 매번 다른 키를 주면 어떨까?
num_moves = len(st.session_state.moves)
if st.button("◀2수", key=f"undo_btn_{num_moves}"):
undo_moves()
st.rerun()
수순이 바뀔 때마다 버튼 키가 바뀐다. undo_btn_50 → undo_btn_48 → undo_btn_46.
여전히 안 됐다. 버튼은 새로 만들어지는데, plotly의 잔상은 여전했다.
시도 3: plotly 컴포넌트에 동적 키
버튼이 아니라 plotly 컴포넌트의 키를 바꿔야 했다.
num_moves = len(st.session_state.moves)
board_key = f"board_{num_moves}"
clicked = plotly_events(
fig,
click_event=True,
key=board_key # 수순마다 다른 키
)
물리기로 num_moves가 50에서 48이 되면, plotly 컴포넌트가 board_50에서 board_48로 바뀐다. 완전히 새로운 컴포넌트다. 이전 클릭 데이터는 없다.
드디어 됐다.
시도 4: 렌더링 전에 처리하기
하지만 또 다른 문제가 있었다. 버튼을 빠르게 연타하면 가끔 씹힌다.
원인은 Streamlit의 실행 순서다. 버튼 클릭 → st.rerun() → 페이지 전체 재실행. 이 과정에서 버튼이 렌더링되기 전에 클릭 상태가 사라질 수 있다.
해결책은 버튼 클릭을 “요청”으로 바꾸는 것이다.
# 버튼은 플래그만 세운다
if st.button("◀2수"):
st.session_state.undo_requested = True
st.rerun()
# main() 시작 부분에서 처리
def main():
if st.session_state.get("undo_requested"):
st.session_state.undo_requested = False
undo_moves()
# ... 나머지 렌더링 ...
버튼은 “물러달라”는 요청만 남기고, 실제 물리기는 다음 실행 사이클 맨 앞에서 처리한다. 렌더링과 로직이 분리된다.
최종 구조
def main():
# 1. 요청 처리 (렌더링 전)
if st.session_state.get("undo_requested"):
st.session_state.undo_requested = False
undo_moves()
# 2. 보드 렌더링 (동적 키)
num_moves = len(st.session_state.moves)
clicked = plotly_events(fig, key=f"board_{num_moves}")
# 3. 버튼 (요청만)
if st.button("◀2수"):
st.session_state.undo_requested = True
st.rerun()
세 가지 조합이 필요했다.
- plotly 동적 키: 이전 클릭 잔상 제거
- 요청-처리 분리: 버튼 상태와 로직 분리
- 렌더링 전 처리: 일관된 실행 순서 보장
교훈
Streamlit은 “매 상호작용마다 전체 스크립트 재실행”이라는 멘탈 모델을 가진다. 단순해 보이지만, 상태가 어디서 어떻게 유지되는지 정확히 알아야 한다.
- session_state: 명시적으로 관리하는 상태
- 위젯 내부 상태: 키가 같으면 유지, 다르면 초기화
- 외부 라이브러리 상태: 예측 불가, 키로 강제 리셋
“버튼이 안 돼요”라는 단순한 버그가 plotly 이벤트 캐싱, Streamlit 실행 모델, 컴포넌트 키 관리까지 건드리는 토끼굴이었다.
한 번 눌리는 버튼을 여러 번 눌리게 만드는 데 반나절이 걸렸다. 그래도 이제 마음껏 물릴 수 있다.
