Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

Rails REST API의 낙관적 잠금

다음과 같은 가상 시나리오를 상상해 보십시오. 임대 자산 관리 시스템에서 직원 A가 임대 X에 대한 연락처 정보 편집을 시작하고 몇 가지 추가 전화 번호를 추가합니다. 거의 같은 시간에 직원 B는 정확히 해당 Rental X의 연락처 정보에 오타를 발견하고 업데이트를 수행합니다. 몇 분 후 직원 A는 Rental X의 연락처 정보를 새 전화번호로 업데이트하고 ... 오타를 수정하는 업데이트는 이제 사라졌습니다!

그것은 확실히 좋지 않습니다! 그리고 이것은 아주 사소한 시나리오입니다. 금융 시스템에서 유사한 충돌이 발생한다고 상상해 보십시오!

앞으로 이러한 시나리오를 피할 수 있습니까? 다행히도 대답은 예입니다! 이러한 문제를 방지하려면 동시성 보호 및 잠금, 특히 낙관적 잠금이 필요합니다.

Rails REST API의 낙관적 잠금을 살펴보겠습니다.

'업데이트 손실' 및 낙관적 잠금 대 비관적 잠금

우리가 방금 겪은 시나리오는 일종의 '업데이트 분실'입니다. 두 개의 동시 트랜잭션이 동일한 행의 동일한 열을 업데이트하면 두 번째 트랜잭션은 기본적으로 첫 번째 트랜잭션이 발생하지 않은 것처럼 첫 번째 트랜잭션의 변경 사항을 무시합니다.

일반적으로 이 문제는 다음을 통해 해결할 수 있습니다.

  • 데이터베이스 수준에서 문제를 처리하는 적절한 트랜잭션 격리 수준 설정
  • 비관적 잠금 — 동시 트랜잭션이 동일한 행을 업데이트하는 것을 방지합니다. 두 번째 트랜잭션은 데이터를 읽기도 전에 첫 번째 트랜잭션이 완료될 때까지 기다립니다. 여기서 가장 큰 장점은 오래된 데이터에 대해 작업할 수 없다는 것입니다. 그러나 가장 큰 단점은 주어진 행에서 데이터를 읽는 것도 차단한다는 것입니다.
  • 낙관적 잠금 — 수정 당시의 상태가 읽을 때와 다른 경우 주어진 행의 수정을 중지합니다.

우리의 문제는 동시 데이터베이스 트랜잭션(비즈니스 트랜잭션과 유사)에 관한 것이 아니므로 첫 번째 솔루션은 실제로 적용할 수 없습니다. 이것은 우리에게 비관적 잠금과 낙관적 잠금이 남아 있음을 의미합니다.

비관적 잠금은 처음부터 가상 시나리오에서 손실된 업데이트가 발생하는 것을 방지합니다. 그러나 매우 오랜 시간 동안 데이터에 대한 액세스를 차단하면 사용자의 삶이 어려워집니다(일부 필드를 30분 이상 읽고 편집한다고 상상해 보세요).

낙관적 잠금은 여러 사용자가 데이터에 액세스할 수 있으므로 훨씬 덜 제한적입니다. 그러나 여러 사용자가 동시에 데이터 편집을 시작하면 한 사람만 작업을 수행할 수 있습니다. 나머지는 부실 데이터로 작업했으며 다시 시도해야 한다는 오류가 표시됩니다. 이상적이지는 않지만 적절한 UX를 사용하면 그렇게 고통스럽지 않을 수도 있습니다.

가상의 Rails REST API에서 낙관적 잠금을 구현하는 방법을 살펴보겠습니다.

REST API의 낙관적 잠금

실제 Rails 앱에서 구현하기 전에 일반적인 REST API의 컨텍스트에서 낙관적 잠금이 어떤 모습일지 생각해 보겠습니다.

위에서 설명한 것처럼 객체를 읽을 때 객체의 원래 상태를 추적하여 업데이트 중 이후 상태와 비교해야 합니다. 마지막 읽기 이후 상태가 변경되지 않으면 작업이 허용됩니다. 그러나 변경되면 실패합니다.

REST API의 컨텍스트에서 파악해야 하는 사항은 다음과 같습니다.

  • 주어진 리소스의 데이터를 읽을 때 객체의 현재 상태를 어떻게 표현하고 소비자에 대한 응답으로 반환합니까?
  • 소비자는 업데이트를 수행할 때 리소스의 원래 상태를 API에 어떻게 전파해야 하나요?
  • 상태가 변경되어 업데이트가 불가능한 경우 API는 소비자에게 무엇을 반환해야 하나요?

좋은 소식은 이러한 모든 질문에 HTTP 의미 체계를 사용하여 답변하고 처리할 수 있다는 것입니다.

리소스 상태를 추적하는 한 Entity Tags를 활용할 수 있습니다. (또는 ETag). 전용 ETag에서 리소스의 지문/체크섬/버전 번호를 반환할 수 있습니다. 나중에 PATCH 요청과 함께 보낼 API 소비자에게 헤더를 보냅니다. If-Match를 사용할 수 있습니다. 헤더를 사용하여 API 서버가 리소스가 변경되었는지 여부를 확인하는 것이 매우 간단합니다. 체크섬/버전 번호/ETag로 선택한 기타 항목을 비교하는 경우일 뿐입니다.

현재 ETagIf-Match 값은 동일합니다. 그렇지 않은 경우 API는 거의 사용되지 않는 412 Precondition Failed로 응답해야 합니다. 상태, 이 목적에 사용할 수 있는 가장 적절하고 표현적인 상태입니다.

또 다른 가능한 시나리오가 있습니다. API 소비자가 If-Match를 제공하는 경우에만 ETag를 비교할 수 있습니다. 헤더. 그렇지 않으면 어떻게 합니까? 동시성 보호를 무시하고 낙관적 잠금을 잊어버릴 수 있지만 이상적이지 않을 수 있습니다. 또 다른 해결책은 If-Match를 제공하도록 요구하는 것입니다. 헤더에 응답하고 428 Precondition Required로 응답 그렇지 않은 경우 상태입니다.

이제 REST API에서 낙관적 잠금이 작동하는 방식에 대한 확실한 개요를 얻었으므로 Rails에서 구현해 보겠습니다.

Rails의 낙관적 잠금

좋은 소식은 Rails가 기본적으로 낙관적 잠금을 제공한다는 것입니다. 우리는 ActiveRecord::Locking::Optimistic에서 제공하는 기능을 사용할 수 있습니다. . lock_version을 추가할 때 열(또는 잠금 열을 정의하기 위해 모델 수준에서 추가 선언이 필요하지만 원하는 다른 모든 것)에 대해 ActiveRecord는 각 변경 후에 이를 증가시키고 현재 할당된 버전이 예상 버전인지 확인합니다. 오래된 경우 ActiveRecord::StaleObjectError 업데이트/파기 시도 시 예외가 발생합니다.

API에서 낙관적 잠금을 처리하는 가장 쉬운 방법은 lock_version의 값을 사용하는 것입니다. ETag로. 가상의 RentalsController의 첫 번째 단계로 이 작업을 수행해 보겠습니다. :

class RentalsController
  after_action :assign_etag, only: [:show]
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  private
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
end

물론 이것은 우리가 인증, 권한 부여 또는 기타 개념이 아닌 낙관적 잠금에 필요한 모든 것에 관심이 있기 때문에 컨트롤러의 매우 단순화된 버전입니다. 이것은 소비자에게 적절한 ETag를 노출하기에 충분합니다. 이제 If-Match를 처리해 보겠습니다. 소비자가 제공할 수 있는 헤더:

class RentalsController
  after_action :assign_etag, only: [:show, :update]
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  def update
    @rental = Rental.find(params[:id])
    @rental.update(rental_params)
    respond_with @rental
  end
 
  private
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
 
  def rental_params
    params
      .require(:rental)
      .permit(:some, :permitted, :attributes).merge(lock_version: lock_version_from_if_match_header)
  end
 
  def lock_version_from_if_match_header
    request.headers["If-Match"].to_i
  end
end

그리고 그것은 실제로 최소한의 낙관적 잠금이 작동하기에 충분합니다! 분명히 충돌이 있는 경우 500개의 응답을 반환하고 싶지는 않습니다. If-Match를 만들 것입니다. 모든 업데이트에도 필요:

class RentalsController
  before_action :ensure_if_match_header_provided, only: [:update]
  after_action :assign_etag, only: [:show, :update]
 
  rescue_from ActiveRecord::StaleObjectError do
    head 412
  end
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  def update
    @rental = Rental.find(params[:id])
    @rental.update(rental_params)
    respond_with @rental
  end
 
  private
 
  def ensure_if_match_header_provided
     request.headers["If-Match"].present? or head 428 and return
  end
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
 
  def rental_params
    params
      .require(:rental)
      .permit(:some, :permitted, :attributes)
      .merge(lock_version: lock_version_from_if_match_header)
  end
 
  def lock_version_from_if_match_header
    request.headers["If-Match"].to_i
  end
end

그리고 이것은 앞에서 논의한 모든 기능을 구현하는 데 필요한 거의 모든 것입니다. 예를 들어 응답 코드 외에 몇 가지 추가 오류 메시지를 제공하여 훨씬 더 많은 것을 개선할 수 있지만 이는 이 문서의 범위를 벗어납니다.

요약:Rails API에서 낙관적 잠금의 중요성

REST API를 설계할 때 동시성 보호가 종종 간과되어 심각한 결과를 초래할 수 있습니다.

그럼에도 불구하고 Rails API에서 낙관적 잠금을 구현하는 것은 이 문서에서 설명한 것처럼 매우 간단하며 잠재적으로 중요한 문제를 방지하는 데 도움이 됩니다.

즐거운 코딩하세요!

추신 Ruby Magic 게시물이 언론에 공개되는 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하고 게시물을 놓치지 마세요!