낙관적 동시 실행 제어(OCC) 사용

낙관적 동시 실행 제어(OCC)는 여러 사용자 또는 프로세스가 동시에 동일한 리소스를 수정할 때 공유 리소스를 관리하고 업데이트 손실 또는 경합 상태를 방지하기 위한 전략입니다.

예를 들어 Identity and Access Management를 사용할 때 IAM 정책은 프로젝트, 버킷 또는 서비스와 같은 리소스에 연결된 공유 리소스입니다. 동시 실행 제어가 없으면 동시 수정으로 인해 최근 정책 변경사항을 덮어쓰게 되어 의도치 않게 합법적인 액세스 권한이 취소되거나 승인되지 않은 권한이 부여될 수 있습니다.

OCC를 구현하기 위해 시스템은 일반적으로 리소스 구조체에서 버전 번호 또는 etag(항목 태그) 필드를 사용합니다.

OCC 작동 방식

두 프로세스 A와 B가 동시에 공유 리소스를 업데이트하려고 한다고 가정해 보겠습니다.

  1. 프로세스 A는 리소스의 현재 상태를 읽습니다.
  2. 프로세스 B가 동일한 현재 상태를 읽습니다.
  3. 프로세스 A가 사본을 수정하고 서버에 다시 씁니다.
  4. 프로세스 B가 사본을 수정하고 서버에 다시 씁니다.

프로세스 B는 프로세스 A가 이미 리소스를 변경했다는 사실을 모른 채 리소스를 덮어쓰므로 프로세스 A의 업데이트가 손실됩니다.

OCC는 항목이 수정될 때마다 변경되는 고유한 디지털 지문을 도입하여 이 문제를 해결합니다. IAM과 같은 많은 시스템에서 이 디지털 지문은 etag입니다. 서버는 쓰기 작업마다 이 태그를 확인합니다.

  1. 리소스를 읽으면 서버가 etag(고유한 디지털 지문)을 반환합니다.
  2. 수정된 리소스를 다시 전송할 때는 원래 etag를 포함해야 합니다.
  3. 저장된 etag가 전송한 etag와 일치하지 않으면(즉, 읽은 후 다른 프로세스에서 리소스를 수정한 경우) 쓰기 작업이 ABORTED 또는 FAILED_PRECONDITION 오류와 함께 실패합니다.

이 실패는 클라이언트에게 업데이트된 상태를 다시 읽고, 변경사항을 다시 적용하고, 새 etag로 쓰기를 다시 시도하는 등 전체 프로세스를 다시 시도하라는 신호를 보냅니다.

OCC 루프 구현

OCC 구현의 핵심은 리소스를 읽고 수정하고 저장하려고 시도하는 재시도 루프로, 쓰기가 성공하면 종료됩니다. 경합이 심한 경우 무한 루프를 방지하려면 적절한 최대 재시도 횟수를 구성하세요.

루프 단계

다음 표에서는 OCC 재시도 루프의 각 단계를 설명합니다.

단계 작업 구현 예시
읽기 etag를 포함한 현재 리소스 상태를 가져옵니다.
let mut policy = client
    .get_iam_policy()
    .set_resource(&secret_name)
    .send()
    .await?;
수정 로컬 구조체에 변경사항을 적용합니다.
policy.bindings.push(new_binding);
작성 및 확인 기존 etag를 사용하여 수정된 리소스를 저장하고 동시 실행 오류 코드를 확인합니다.
match client
    .set_iam_policy()
    .set_resource(&secret_name)
    .set_policy(policy)
    .send()
    .await
{
    Ok(p) => return Ok(p),
    Err(e) => { /* ... */ }
}
성공 또는 다시 시도 쓰기에 성공하면 루프를 종료합니다. 동시 실행 오류로 인해 실패하면 재시도 카운터를 늘리고 읽기 단계부터 루프를 계속합니다. -

예

다음 예시에서는 구성원에게 roles/secretmanager.secretAccessor 역할을 부여하여 Secret Manager 보안 비밀 리소스에서 IAM 정책을 업데이트하는 OCC 루프를 구현하는 방법을 보여줍니다.

이 예시를 실행하려면 먼저 Cargo.toml 파일에서 종속 항목을 선언합니다.

cargo add google-cloud-secretmanager-v1

그런 다음 OCC 재시도 루프를 구현합니다.

const ROLE: &str = "roles/secretmanager.secretAccessor";
const ATTEMPTS: u32 = 5;

let secret_name = format!("projects/{project_id}/secrets/{secret_id}");
let client = SecretManagerService::builder().build().await?;
for _attempt in 0..ATTEMPTS {
    let mut current = client
        .get_iam_policy()
        .set_resource(&secret_name)
        .send()
        .await?;

    match current.bindings.iter_mut().find(|b| b.role == ROLE) {
        None => current
            .bindings
            .push(Binding::new().set_role(ROLE).set_members([member])),
        Some(b) => {
            if b.members.iter().any(|m| m == member) {
                return Ok(current);
            }
            b.members.push(member.to_string());
        }
    };
    let updated = client
        .set_iam_policy()
        .set_resource(&secret_name)
        .set_policy(current)
        .set_update_mask(FieldMask::default().set_paths(["bindings"]))
        .send()
        .await;
    match updated {
        Ok(p) => return Ok(p),
        Err(e)
            if e.status().is_some_and(|s| {
                s.code == Code::Aborted || s.code == Code::FailedPrecondition
            }) =>
        {
            continue;
        }
        Err(e) => return Err(e.into()),
    }
}
anyhow::bail!("could not set IAM policy after {ATTEMPTS} attempts")

코드 완성

다음 코드는 필요한 모든 가져오기가 포함된 전체 함수를 보여줍니다.

use google_cloud_gax::error::rpc::Code;
use google_cloud_iam_v1::model::Binding;
use google_cloud_iam_v1::model::Policy;
use google_cloud_secretmanager_v1::client::SecretManagerService;
use google_cloud_wkt::FieldMask;

/// Executes an Optimistic Concurrency Control (OCC) loop to safely update a resource.
///
/// This function demonstrates the core Read-Modify-Write-Retry pattern. It uses the secret manager
/// service and a hard-coded role. The principles apply to any other service or role.
///
/// # Parameters
/// * `project_id` The Google Cloud Project ID (e.g., "my-project-123").
/// * `secret_id` The Google Cloud Project ID (e.g., "my-secret").
/// * `member` The member to add (e.g., "user:user@example.com").
///
/// # Returns
/// The new IAM policy.
pub async fn sample(project_id: &str, secret_id: &str, member: &str) -> anyhow::Result<Policy> {
    const ROLE: &str = "roles/secretmanager.secretAccessor";
    const ATTEMPTS: u32 = 5;

    let secret_name = format!("projects/{project_id}/secrets/{secret_id}");
    let client = SecretManagerService::builder().build().await?;
    for _attempt in 0..ATTEMPTS {
        let mut current = client
            .get_iam_policy()
            .set_resource(&secret_name)
            .send()
            .await?;

        match current.bindings.iter_mut().find(|b| b.role == ROLE) {
            None => current
                .bindings
                .push(Binding::new().set_role(ROLE).set_members([member])),
            Some(b) => {
                if b.members.iter().any(|m| m == member) {
                    return Ok(current);
                }
                b.members.push(member.to_string());
            }
        };
        let updated = client
            .set_iam_policy()
            .set_resource(&secret_name)
            .set_policy(current)
            .set_update_mask(FieldMask::default().set_paths(["bindings"]))
            .send()
            .await;
        match updated {
            Ok(p) => return Ok(p),
            Err(e)
                if e.status().is_some_and(|s| {
                    s.code == Code::Aborted || s.code == Code::FailedPrecondition
                }) =>
            {
                continue;
            }
            Err(e) => return Err(e.into()),
        }
    }
    anyhow::bail!("could not set IAM policy after {ATTEMPTS} attempts")
}