Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Rails REST API에서 낙관적 잠금(Optimistic Locking) 구현하기

다음과 같은 가상의 시나리오를 상상해 보세요. 임대 부동산 관리 시스템에서 직원 A가 임대 매물 X의 연락처 정보를 편집하며 추가 전화번호들을 입력하고 있습니다. 거의 같은 시점에 직원 B가 바로 그 임대 매물 X의 연락처 정보에서 오타를 발견하고 이를 수정합니다. 몇 분 후, 직원 A가 새로운 전화번호를 포함해 임대 매물 X의 연락처 정보를 업데이트하면… 오타를 고쳤던 업데이트는 흔적도 없이 사라져 버립니다!

이건 분명 좋지 않은 상황입니다! 그리고 이조차 꽤 단순한 시나리오입니다. 만약 이런 충돌이 금융 시스템에서 발생한다면 어떤 일이 벌어질지 상상해 보세요.

그렇다면 앞으로 이런 시나리오를 피할 수 있을까요? 다행히 답은 '예'입니다! 이런 문제를 방지하려면 동시성 제어와 잠금, 구체적으로는 낙관적 잠금(Optimistic Locking)이 필요합니다.

지금부터 Rails REST API에서의 낙관적 잠금을 자세히 살펴보겠습니다.

'유실된 업데이트'와 낙관적 잠금 vs 비관적 잠금

방금 살펴본 시나리오는 '유실된 업데이트(Lost Update)'의 한 유형입니다. 두 개의 동시 트랜잭션이 동일한 행(row)의 같은 컬럼을 업데이트하면, 두 번째 트랜잭션이 첫 번째 트랜잭션의 변경 사항을 덮어써 버려 마치 첫 번째 트랜잭션이 아예 일어나지 않은 것처럼 됩니다.

일반적으로 이 문제는 다음과 같은 방법으로 해결할 수 있습니다:

  • 적절한 트랜잭션 격리 수준(Transaction Isolation Level) 설정 — 데이터베이스 수준에서 문제를 처리합니다.
  • 비관적 잠금(Pessimistic Locking) — 동시 트랜잭션이 같은 행을 업데이트하지 못하도록 차단합니다. 두 번째 트랜잭션은 첫 번째 트랜잭션이 끝난 후에야 데이터를 읽을 수 있습니다. 오래된(stale) 데이터를 기반으로 작업하는 일이 원천적으로 불가능하다는 것이 큰 장점이지만, 해당 행의 데이터 읽기까지 차단한다는 점이 큰 단점입니다.
  • 낙관적 잠금(Optimistic Locking) — 수정 시점의 행 상태가 데이터를 읽었을 때의 상태와 다르면 해당 행의 수정을 중단합니다.

우리가 직면한 문제는 동시 데이터베이스 트랜잭션이라기보다 비즈니스 트랜잭션에 가깝기 때문에, 첫 번째 해결책은 사실상 적용하기 어렵습니다. 따라서 선택지는 비관적 잠금과 낙관적 잠금 두 가지로 좁혀집니다.

비관적 잠금은 가상 시나리오에서 유실된 업데이트가 처음부터 발생하지 않도록 막아줍니다. 하지만 데이터 접근을 오랫동안 차단하게 되면 사용자 경험이 크게 나빠집니다(30분 넘게 필드를 읽고 편집하는 상황을 상상해 보세요).

낙관적 잠금은 훨씬 덜 제한적이어서 여러 사용자가 동시에 데이터에 접근할 수 있습니다. 다만 여러 사용자가 동시에 데이터를 편집하기 시작하면 한 명만 작업을 완료할 수 있고, 나머지는 오래된 데이터를 기반으로 작업했다는 에러를 보고 재시도해야 합니다. 이상적이진 않지만, 적절한 UX를 갖추면 크게 불편하지 않을 수 있습니다.

이제 가상의 Rails REST API에서 낙관적 잠금을 어떻게 구현할 수 있는지 알아보겠습니다.

REST API에서의 낙관적 잠금

실제 Rails 앱에서 구현하기 전에, 일반적인 REST API의 관점에서 낙관적 잠금이 어떤 모습일지 먼저 생각해 봅시다.

앞서 설명했듯이, 데이터를 읽을 때 객체의 원래 상태를 추적해 두었다가 이후 업데이트 시점의 상태와 비교해야 합니다. 마지막으로 읽은 이후 상태가 변경되지 않았다면 작업이 허용되고, 변경되었다면 실패합니다.

REST API의 맥락에서 파악해야 할 사항은 다음 세 가지입니다:

  • 특정 리소스의 데이터를 읽을 때, 객체의 현재 상태를 어떻게 표현하고 응답으로 소비자(consumer)에게 반환할 것인가?
  • 소비자는 업데이트를 수행할 때 리소스의 원래 상태를 API에 어떻게 전달해야 하는가?
  • 상태가 변경되어 업데이트가 불가능한 경우, API는 소비자에게 무엇을 반환해야 하는가?

다행히 이 모든 질문은 HTTP 의미론(semantics)만으로 답변하고 처리할 수 있습니다.

리소스 상태 추적에는 Entity Tags(ETag)를 활용할 수 있습니다. 리소스의 지문(fingerprint)/체크섬/버전 번호를 전용 ETag 헤더에 담아 API 소비자에게 반환하고, 소비자는 이후 PATCH 요청과 함께 이 값을 전송합니다. If-Match 헤더를 사용하면 API 서버가 리소스의 변경 여부를 확인하는 작업이 매우 간단해집니다. 체크섬이나 버전 번호 등 ETag로 선택한 값을 비교하기만 하면 됩니다.

현재 ETag 값과 If-Match 값이 일치하면 요청이 성공합니다. 일치하지 않으면 API는 실무에서 잘 사용되지 않는 412 Precondition Failed 상태로 응답해야 합니다. 이 목적에 가장 적합하고 표현력 있는 상태 코드입니다.

또 하나 고려할 시나리오가 있습니다. ETag를 비교하려면 API 소비자가 If-Match 헤더를 반드시 제공해야 합니다. 만약 제공하지 않는다면 어떻게 될까요? 동시성 보호를 무시하고 낙관적 잠금을 포기할 수도 있지만, 그다지 이상적이지 않습니다. 더 나은 해결책은 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에 낙관적 잠금을 구현하는 것은 생각보다 간단하며, 잠재적으로 치명적인 문제를 예방하는 데 큰 도움이 됩니다.

즐거운 코딩 되세요!

P.S. Ruby Magic의 게시글이 발행되자마자 바로 읽어보고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!