1. 문제의 발단: 운영 환경에서의 상태 불일치
Rails 8 기반의 포커 타이머 앱에서 사용자가 ‘5분 차감’ 버튼을 빠르게 여러 번 클릭할 때, 기기마다 표시되는 시간이 달라지는 문제가 발생했습니다. 서버의 데이터베이스는 정확한 상태를 유지하고 있었지만, 특정 클라이언트는 마지막 업데이트를 반영하지 못한 채 이전 상태에 머물러 있었습니다. 이 버그는 페이지를 새로고침하면 즉시 해결되었기에 원인 파악이 매우 까다로웠습니다.
2. 초기 아키텍처와 잠재적 위험 요소
해당 기능은 전형적인 Rails 패턴을 따르고 있었습니다: - 사용자가 버튼 클릭 시 Turbo가 폼을 제출합니다. - 서버는 데이터를 업데이트한 후 ActionCable을 통해 모든 클라이언트에 새 상태를 브로드캐스트합니다. - 서버는 제출한 클라이언트에게 리다이렉트 응답을 보냅니다. - Turbo는 리다이렉트를 따라 페이지를 다시 렌더링합니다.
이 과정에서 클릭한 기기는 ActionCable 브로드캐스트와 Turbo 리다이렉트 재렌더링이라는 두 가지 경로로 데이터를 받게 됩니다. 로컬 환경에서는 지연 시간이 거의 없어 문제가 없었으나, 실제 네트워크 환경에서는 이 두 경로가 서로 충돌하기 시작했습니다.
3. 실패한 첫 번째 시도: 리다이렉트 제거
작성자는 리다이렉트 과정에서 Stimulus 컨트롤러가 연결 해제(disconnect)되고 재연결(connect)되는 찰나의 순간에 ActionCable 구독이 끊기며 브로드캐스트를 놓친다고 판단했습니다. 이에 따라 리다이렉트 대신 head :no_content를 반환하도록 수정했습니다. 하지만 이 수정 후에도 빠른 클릭 시 여전히 상태 불일치가 발생했습니다.
4. 근본 원인: Turbo Navigator의 중단(Abort) 메커니즘
심층 분석 결과, 범인은 Turbo의 내부 생명주기 관리에 있었습니다. Turbo의 Navigator는 새로운 폼 제출이 시작될 때 이전의 진행 중인(in-flight) 요청이 있다면 this.stop()을 호출하여 강제로 중단시킵니다.
- 사용자가 버튼을 연타하면 이전 요청이 중단되거나, 버튼의 활성/비활성 상태가 꼬이게 됩니다.
- 서버는 요청을 받아 처리하고 브로드캐스트를 보내지만, 클라이언트 측 Turbo가 요청을 취소하거나 응답 처리를 방해하면서 상태 업데이트의 일관성이 깨집니다.
- 결과적으로 클릭 횟수, 서버 처리 횟수, 클라이언트 수신 횟수가 일치하지 않는 비대칭적 상황이 발생합니다.
5. 최종 해결책: Turbo 우회 및 단일 경로 확보
문제를 완전히 해결하기 위해 실시간 브로드캐스트가 동반되는 액션에서는 Turbo의 개입을 완전히 제거해야 합니다.
- Step 1: data-turbo=”false” 설정: 폼 제출 시 Turbo Navigator를 거치지 않고 브라우저 표준 HTTP 요청을 사용하도록 합니다. 이를 통해 요청 중단이나 버튼 상태 관리의 간섭을 피할 수 있습니다.
- Step 2: head :no_content 반환: 브라우저가 204 응답을 받으면 현재 페이지를 유지하므로, 화면 업데이트는 오직 ActionCable 브로드캐스트를 통해서만 이루어집니다.
이 방식을 통해 모든 클라이언트(클릭한 기기와 관찰하는 기기 모두)가 동일한 ActionCable 경로로만 업데이트를 받게 되어 레이스 컨디션이 원천 차단됩니다.