Turbo Streams, Hotwire 및 StimulusJS를 활용한 Rails 애플리케이션의 편집 충돌 방지

Preventing edit conflicts in Rails with Turbo/Hotwire and StimulusJS

작성자
발행일
2025년 10월 14일

핵심 요약

  • 1 Rails 애플리케이션에서 Turbo Streams와 StimulusJS를 활용하여 실시간 협업 편집 시 발생하는 충돌을 효과적으로 방지하는 방법 제시.
  • 2 사용자가 편집을 시작할 때 레코드를 잠그고, 잠금 상태를 실시간으로 모든 클라이언트에 브로드캐스트하여 동시 편집을 제어하는 메커니즘 구축.
  • 3 navigator.sendBeacon과 StimulusJS를 통해 사용자가 페이지를 이탈하거나 탭을 닫을 때 잠금을 자동으로 해제하는 견고한 방법 구현.

도입

Rails 애플리케이션에서 협업 기능을 구축할 때 여러 사용자가 동시에 동일한 레코드를 편집하여 발생하는 충돌을 방지하는 것은 중요한 과제입니다. 기존에는 낙관적 잠금(optimistic locking)이나 복잡한 병합 전략이 사용되었으나, 때로는 단순히 "다른 사용자가 편집 중이니 나중에 다시 시도하세요"와 같은 직관적인 접근 방식이 필요합니다. 본 문서는 사용자가 태스크를 편집할 때 충돌을 방지하기 위해 레코드를 잠그고, 잠금 상태를 모든 연결된 클라이언트에 브로드캐스트하며, 사용자가 페이지를 벗어날 때 자동으로 잠금을 해제하는 간단하면서도 효과적인 패턴을 소개합니다.

태스크 모델 설정

협업 앱의 실시간 잠금 처리를 위해 Task 모델에 locked_by (사용자 참조)와 locked_at (날짜/시간) 컬럼을 추가합니다. ruby class AddLockingToTasks < ActiveRecord::Migration[8.0] def change add_reference :tasks, :locked_by, foreign_key: { to_table: :users } add_column :tasks, :locked_at, :datetime end end Task 모델은 잠금 로직을 처리하고 변경 사항을 연결된 사용자에게 브로드캐스트합니다. ruby # app/models/task.rb class Task < ApplicationRecord after_create_commit { broadcast_prepend_to "tasks", target: "tasks" } after_update_commit { broadcast_replace_to 'tasks' } # 잠금/해제 시 업데이트 브로드캐스트 after_destroy_commit { broadcast_remove_to 'tasks' } def locked_by?(user) locked_by == user end def locked? locked_by.present? end def lock!(user) update!(locked_by: user, locked_at: Time.current) end def unlock! update!(locked_by: nil, locked_at: nil) end end after_update_commit 콜백은 태스크가 잠기거나 해제될 때마다 Turbo Streams를 통해 모든 사용자에게 업데이트된 버전을 전송하는 핵심 역할을 합니다.

편집 시작 시 태스크 잠금

사용자가 편집 버튼을 클릭하면 태스크는 즉시 잠기고 변경 사항이 브로드캐스트됩니다. ruby # app/controllers/tasks_controller.rb class TasksController < ApplicationController def edit if @task.locked? && !@task.locked_by?(current_user) redirect_to root_path, alert: "This task is currently being edited by #{@task.locked_by.name}." return end @task.lock!(current_user) end def update if @task.update(task_params) @task.unlock! redirect_to root_path, notice: "Task was successfully updated." else render :edit, status: :unprocessable_entity end end def unlock @task.unlock! if @task.locked_by?(current_user) head :ok end end edit 액션에서는 이미 잠겨있는 경우 현재 사용자가 잠근 것이 아니라면 리디렉션하고, 그렇지 않으면 태스크를 잠급니다. update 액션 성공 시 태스크를 해제합니다. unlock 액션은 명시적으로 잠금을 해제하는 엔드포인트입니다.

모든 사용자에게 잠금 상태 표시

태스크 부분 템플릿은 잠금 상태에 따라 다른 UI를 표시합니다. ```erb

... <% if task.locked? %> <%= task.locked_by.name %> is editing this task <% end %> ... <% if task.locked? %> <% else %> <%= link_to "Edit", edit_task_path(task) %> <% end %>

``` index 페이지는 turbo_stream_from "tasks"를 통해 Turbo Stream 채널을 구독하여, 태스크의 변경(잠금, 해제, 생성, 업데이트, 삭제)이 발생할 때마다 모든 연결된 클라이언트가 즉시 업데이트를 받습니다.

사용자가 페이지를 벗어날 때 잠금 해제

사용자가 저장 또는 취소를 클릭하지 않고 탭을 닫거나 뒤로 가기 버튼을 누르는 등 페이지를 이탈할 때 잠금이 자동으로 해제되어야 합니다. StimulusJS 컨트롤러는 turbo:before-visit (앱 내비게이션) 및 beforeunload (탭 닫기, 페이지 새로고침) 이벤트를 수신하여 잠금 해제 요청을 보냅니다. javascript // app/javascript/controllers/task_lock_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { taskId: Number } connect() { this.unlock = this.unlock.bind(this) document.addEventListener("turbo:before-visit", this.unlock) window.addEventListener("beforeunload", this.unlock) } unlock() { const formData = new FormData() formData.append("authenticity_token", document.querySelector("[name='csrf-token']").content) navigator.sendBeacon(`/tasks/${this.taskIdValue}/unlock`, formData) } } 일반적인 AJAX 요청은 페이지 언로드 시 취소될 수 있으므로, navigator.sendBeacon()을 사용하여 페이지가 닫히는 중에도 데이터를 안정적으로 전송합니다.

결론

이 접근 방식의 핵심은 그 단순성에 있습니다. 별도의 WebSocket 관리나 복잡한 상태 동기화, 폴링 없이도 Turbo Streams와 StimulusJS가 Rails의 컨벤션을 통해 모든 것을 처리합니다. 사용자가 태스크 편집을 클릭하면 즉시 잠기고, 다른 사용자들은 실시간으로 잠금 상태 업데이트를 받습니다. 이는 복잡한 인프라 없이도 Rails 스택 내의 도구만으로 부드러운 실시간 협업 경험을 구축할 수 있음을 보여줍니다. 각 도구의 강점을 이해하고 이를 사려 깊게 결합하는 것이 효과적인 협업 기능 구현의 열쇠입니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!