글자 크기

물리기 버튼이 한 번만 작동하는 이유

“◀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가 반환하는 클릭 데이터가 “잔상”처럼 남는다는 것이다.

  1. 사용자가 Q16에 착수 (클릭)
  2. AI가 응수
  3. 사용자가 “◀2수” 클릭
  4. st.rerun() 발생
  5. plotly_events가 이전 Q16 클릭을 다시 반환
  6. Q16에 다시 착수됨
  7. AI가 다시 응수
  8. 원점으로 돌아옴

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()

세 가지 조합이 필요했다.

  1. plotly 동적 키: 이전 클릭 잔상 제거
  2. 요청-처리 분리: 버튼 상태와 로직 분리
  3. 렌더링 전 처리: 일관된 실행 순서 보장

교훈

Streamlit은 “매 상호작용마다 전체 스크립트 재실행”이라는 멘탈 모델을 가진다. 단순해 보이지만, 상태가 어디서 어떻게 유지되는지 정확히 알아야 한다.

  • session_state: 명시적으로 관리하는 상태
  • 위젯 내부 상태: 키가 같으면 유지, 다르면 초기화
  • 외부 라이브러리 상태: 예측 불가, 키로 강제 리셋

“버튼이 안 돼요”라는 단순한 버그가 plotly 이벤트 캐싱, Streamlit 실행 모델, 컴포넌트 키 관리까지 건드리는 토끼굴이었다.

한 번 눌리는 버튼을 여러 번 눌리게 만드는 데 반나절이 걸렸다. 그래도 이제 마음껏 물릴 수 있다.

댓글 달기

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