본문 바로가기

시스템/Devops

Streamlit 리런 뒤 선택 탭을 유지하는 방법

저장은 성공했는데 화면은 실패처럼 보이는 증상

Streamlit 검증 화면에서 사용자가 Lv4·5 이슈의 검증을 신청하면 요청자와 pending 상태는 정상적으로 저장됐다. 문제는 저장 직후 실행한 st.rerun()이었다. 사용자가 보고 있던 검증 탭이 아니라 1번 탭이 다시 열려, 방금 제출한 신청 카드와 변경된 대기 건수를 바로 확인할 수 없었다.

이 문제는 서버 저장 결과만 검사하면 발견되지 않는다. 저장 성공, 리런 이후 선택 섹션, 신청 카드 노출, 대기 배지 변화까지 하나의 완료 조건으로 묶어야 한다. 실제 수정 후에는 뷰어 화면에 신청 카드와 대기 1건이 표시됐고, 판정 버튼은 0개였으며 남은 신청 대상은 18건에서 17건으로 줄었다.

문제가 된 st.tabs 기반 신청 흐름

list_tab, review_tab = st.tabs(["목록", "검증"])

with review_tab:
    if st.button("검증 신청"):
        save_request(requester="<요청자>", status="pending")
        st.rerun()

st.tabs가 리런 뒤 첫 탭으로 돌아가는 이유

st.tabs는 화면을 나누기에는 간단하지만 이 사례에서 필요한 선택 상태용 key를 제공하지 않았다. 버튼 처리에서 st.rerun()이 호출되면 스크립트가 처음부터 다시 실행되고, 새로 만들어진 탭 UI는 사용자가 직전에 선택한 탭을 복원하지 못했다. 저장 함수가 성공했는지와 사용자가 어느 탭에 남는지는 서로 다른 검증 대상이다.

처음에는 화면 구획만 확인하고 st.tabs를 선택했다. 실제 동작에서 리런 이후 선택 보존이 필요하다는 조건을 확인한 뒤, 상태 key를 가질 수 있는 segmented_control로 교체했다. 실패 지점은 저장 계층이 아니라 리런 이후 UI를 재구성하는 분기였다.

리런 전후 선택 상태와 실패 분기

[사용자]
   |
   v
[검증 섹션 선택] ---> [신청 저장: requester, pending]
   |                              |
   |                              v
   |                         [st.rerun()]
   |                              |
   +------------------------------+
                                  |
                    +-------------+-------------+
                    |                           |
                    v                           v
          [st.tabs: 상태 key 없음]   [segmented_control: key 있음]
                    |                           |
                    v                           v
          [1번 탭으로 초기화]          [검증 섹션 선택 복원]
                    |                           |
                    v                           v
          [성공 결과 확인 실패]        [카드와 대기 1건 확인]
관찰한 증상 처음 판단하기 쉬운 원인 확인된 원인과 조치
신청 후 1번 탭으로 이동 신청 저장 실패 st.tabs가 선택 상태를 보존하지 못함. 상태 키가 있는 위젯으로 교체
대기 건수는 저장됐지만 바로 보이지 않음 조회 데이터 갱신 실패 리런 후 다른 섹션이 열림. 같은 섹션에 남도록 상태 유지
판정 사유가 빈 값으로 저장됨 제품의 저장 로직 오류 자동화가 blur를 만들지 않은 현상인지 먼저 재검증
배지 변경 후 선택 초기화 가능 리런 자체의 문제 옵션 라벨을 고정하고 대기 건수는 별도로 렌더링

상태 key와 고정된 옵션 값으로 교체하기

선택 상태는 st.session_state에 초기값을 두고 segmented_control의 고정된 key에 연결한다. 사용자가 검증 섹션을 선택하면 그 값이 세션에 남고, 신청 처리 후 리런돼도 같은 위젯 키가 같은 값을 읽는다. 다음 코드는 탭을 상태 기반 섹션 선택기로 바꾼 최소 구조다.

상태 key를 가진 segmented_control 교체 코드

if "selected_section" not in st.session_state:
    st.session_state.selected_section = "목록"

selected_section = st.segmented_control(
    "화면",
    options=["목록", "검증"],
    key="selected_section",
)

if selected_section == "검증":
    render_review_section()

여기에는 대기 건수 배지를 옵션 문자열에 합치지 않는 조건도 포함돼야 한다. 예를 들어 선택값 자체가 검증 · 대기 1건이라면 건수가 바뀌는 순간 기존 선택값이 옵션 목록에서 사라질 수 있다. 작업 중에도 라벨 변경이 선택값을 초기화할 수 있다는 추가 함정을 확인했으므로, 옵션은 목록과 검증처럼 고정하고 배지는 별도 출력으로 분리했다.

변하는 배지 라벨을 옵션에서 분리하는 전후 비교

# 문제: 건수가 바뀌면 선택값 문자열도 바뀐다.
changing_options = ["목록", f"검증 · 대기 {pending_count}건"]

# 수정: 옵션 값은 고정하고 건수는 별도로 표시한다.
stable_options = ["목록", "검증"]
st.write(f"대기 {pending_count}건")

저장과 리런을 하나의 사용자 흐름으로 묶기

신청 버튼의 완료 조건은 요청자와 pending을 저장하는 데서 끝나지 않는다. 저장 후 st.rerun()이 실행되고, 동일한 검증 섹션에서 신청 카드와 갱신된 건수가 보여야 한다. 콜백은 저장을 수행하되 선택 위젯의 키와 옵션 값은 변경하지 않아야 한다.

신청 저장 후 같은 선택 상태로 리런하는 콜백

def submit_review():
    save_request(requester="<요청자>", status="pending")
    st.rerun()

st.button(
    "검증 신청",
    on_click=submit_review,
)

권한별 노출도 리런 이후 다시 검사한다. 라이브 검증에서는 카드 147장이 오류 없이 렌더링됐고, 관리자가 저장한 난이도는 ★★★★★와 메모로 표시됐다. 뷰어는 카드와 난이도를 볼 수 있었지만 관리자 부여 영역과 판정 버튼은 볼 수 없었으므로, 뷰어 화면의 판정 버튼 0개는 정상 결과다.

승인 흐름에서는 확정 Lv5와 판정 사유가 저장되고 대기 배지가 사라지는 것까지 확인했다. 따라서 테스트는 저장 레코드만 조회하지 말고 리런 뒤 선택값, 역할별 버튼 수, 카드 노출, 대기 배지 제거를 함께 단언해야 한다. 이 네 항목 중 하나라도 빠지면 서버 성공과 화면 성공이 분리된 결함을 놓칠 수 있다.

자동화의 click 현상을 제품 버그와 구분하기

Streamlit의 text_area는 이 검증에서 포커스가 빠지는 blur 시점에 값이 확정됐다. JavaScript로 입력 요소를 바꾼 뒤 버튼의 .click()만 호출하면 포커스가 이동하지 않아, 승인 후 판정 사유가 빈 값으로 저장됐다. 이 결과만으로 저장 로직의 실제 버그라고 단정하지 않았다.

재검증에서는 입력 뒤 명시적으로 blur를 발생시키고, 승인 동작도 자동화 도구의 실제 키 입력 경로로 수행했다. 아래 첫 흐름은 값 확정을 건너뛸 수 있는 방식이고, 둘째 흐름은 포커스 전환 후 승인하는 방식이다. realKeys와 요소 탐색 함수는 사용하는 자동화 드라이버의 어댑터로 연결한다.

UI 자동화에서 click 전용 흐름과 blur 후 실제 키 입력 흐름 비교

// 문제 재현: 포커스가 남아 값이 확정되지 않을 수 있다.
reasonTextArea.focus();
reasonTextArea.value = "<판정 사유>";
approveButton.click();

// 재검증: blur 후 자동화 드라이버의 실제 키 입력을 사용한다.
await realKeys.type(reasonTextArea, "<판정 사유>");
reasonTextArea.blur();
await realKeys.press(approveButton, "Enter");
assertSavedReason("<판정 사유>");

판정 기준은 동일한 데이터에서 두 입력 경로를 비교하는 것이다. 실제 키 입력에서는 사유가 저장되고 .click()에서만 빈 값이라면 자동화 아티팩트 가능성을 먼저 처리한다. 두 경로 모두 빈 값일 때 제품의 이벤트 처리와 저장 요청을 조사해야 한다.

재현 데이터와 완료 조건을 고정하기

검증 대상 데이터의 범위도 UI 상태 테스트와 함께 고정했다. 원본에서 에픽 자체 행은 453건이었고, 진행 중·대기 상태 123건을 퀘스트 후보로 확인했다. 에픽 453건 중 394건에는 자체 SP가 없었으며, SP 4·5 이슈 111건은 Lv4 91건과 Lv5 20건으로 구성됐다.

처음에는 퀘스트를 진행률 순으로 정렬했지만 이미 끝난 백로그 에픽이 상단을 차지했다. 진행률이 높을수록 먼저 보여야 한다는 판단을 버리고 규모인 SP 순으로 변경했다. 리런 상태 테스트에서도 어떤 카드가 상단에 있어야 하는지 정렬 기준을 함께 고정해야, 다른 카드가 보이는 현상을 탭 초기화와 혼동하지 않는다.

수정한 Streamlit 앱을 실행해 리런 흐름을 검증하는 명령

# 수정한 앱 파일을 지정한다.
streamlit run <여기에 앱 파일>
# 열린 검증 화면에서 신청과 승인 흐름을 순서대로 실행한다.

최종 확인 순서는 검증 섹션 선택, 신청, pending 저장, st.rerun(), 동일 섹션 유지, 신청 대상 18건에서 17건으로 감소, 대기 1건 노출이다.