백엔드

단일 실행기 작업을 위한 CAS 및 하트비트를 사용한 임대 잠금

홀더가 죽으면 일반적인 락은 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기에 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 만드는 방법과 설계를 결정하는 실패 모드가 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

어떤 작업은 한 번에 하나씩만 실행되어야 합니다. 인덱스를 다시 빌드하는 수집 작업, 계정에 요금을 청구하는 야간 배치 작업, 행을 제자리에서 변경하는 마이그레이션 작업 등이 그 예입니다. 두 복사본을 동시에 실행하면 이중 쓰기, 손상된 상태 또는 어떤 테스트로도 재현할 수 없는 경쟁 상태가 발생합니다. 가장 단순한 해결책은 잠금(lock)이지만, 이 단순한 잠금에는 치명적인 결함이 있습니다. 잠금을 보유한 프로세스가 중단되면 잠금은 영원히 유지되고 해당 작업은 다시는 실행되지 않습니다. 임대(lease)는 만료됨으로써 이 문제를 해결합니다. 이 게시물에서는 compare-and-swap 획득과 하트비트를 사용하는 임대 잠금(lease lock)을 구축하고, 설계의 각 부분을 구성하게 된 실패 모드를 살펴봅니다.

단순한 락으로는 충분하지 않은 이유

본능적으로 플래그를 설정하는 방법을 사용하게 됩니다. 작업 전에 locked = true를 쓰고, 작업 후에 삭제하는 것입니다. 이 방법은 프로세스가 중간에 충돌하기 전까지는 잘 작동합니다. 이제 플래그는 true 상태로 고정되고, 다른 워커는 시작되지 않으며, 사람이 알아채고 수동으로 정리할 때까지 작업은 중단됩니다. 동시성 버그를 가용성 버그와 맞바꾼 셈이며, 그 가용성 버그는 밤에 당신을 깨우게 됩니다.

핵심 문제는 단순한 락에는 락 보유자가 여전히 살아있는지에 대한 개념이 없다는 것입니다. 락은 누군가 락을 획득했다는 사실만 기록할 뿐, 누군가 여전히 작업 중이라는 사실은 기록하지 않습니다. 죽은 보유자로부터 복구할 수 없는 락은 잘못된 순간을 기다리는 교착 상태(deadlock)입니다. 실제로 여러분이 원하는 것은 정상적인 워커는 유지하되, 충돌한 워커는 자동으로 포기하는 락입니다. 그것이 바로 리스(lease)입니다.

리스는 데드라인이 있는 잠금입니다

리스는 갱신되지 않으면 만료되는 잠금입니다. "워커 A가 이것을 보유한다" 대신 "워커 A가 시간 T까지 이것을 보유한다"고 기록합니다. A가 살아있으면, T를 계속 미래로 미룹니다. A가 죽으면, T가 도래하고, 리스는 해제된 것으로 간주되어 다른 워커가 가져갈 수 있습니다. 사람의 개입 없이 잠금이 스스로 복구됩니다.

세 가지 작업이 전체 메커니즘을 구성합니다:

  • 획득(Acquire). 리스가 해제되었거나 만료된 경우에만 리스를 가져가고, 누가 가져갔는지와 언제 만료되는지를 기록합니다.
  • 하트비트(Heartbeat). 작업하는 동안 주기적으로 만료 시간을 연장하여 살아있는 보유자가 리스를 잃지 않도록 합니다.
  • 해제(Release). 정상적으로 완료되면 즉시 리스를 포기하여 다음 실행이 만료를 기다릴 필요가 없도록 합니다.

미묘한 부분은 획득과 하트비트 모두, 동일한 순간에 동일한 작업을 시도하는 다른 모든 워커에 대해 원자적(atomic)이어야 한다는 것입니다. 바로 이 지점에서 compare-and-swap이 사용됩니다.

compare-and-swap으로 획득하기

획득 중의 위험은 확인하는 것과 가져가는 것 사이의 간격입니다. 만약 한 워커가 "리스가 비어있다"고 읽은 다음 "내가 보유한다"고 쓰는 것을 두 개의 개별 단계로 수행하면, 두 번째 워커가 읽기와 쓰기 사이에 끼어들 수 있고, 이제 두 워커 모두 자신이 리스를 보유하고 있다고 믿게 됩니다. 확인과 획득은 하나의 분리할 수 없는 작업이어야 합니다.

Compare-and-swap은 바로 그것을 제공합니다. 즉, 값이 여전히 예상했던 것과 일치하는 경우에만 값을 설정하는 것입니다. 원자적 조건부 쓰기를 지원하는 스토어에서 획득은 단일 보호 작업이 됩니다. 다음은 문서 스토어에 대한 예시로, 현재 보유자가 없거나 현재 보유자의 리스가 이미 만료된 경우에만 리스를 획득합니다.

// Acquire only if nobody holds the lease or the holder's lease has expired.
// The filter and the update are one atomic operation, so two workers cannot
// both win.
now := time.Now()
res, err := coll.UpdateOne(ctx,
	bson.M{
		"_id": lockKey,
		"$or": []bson.M{
			{"holder": ""},
			{"expires_at": bson.M{"$lt": now}},
		},
	},
	bson.M{"$set": bson.M{
		"holder":     workerID,
		"expires_at": now.Add(leaseTTL),
	}},
	options.Update().SetUpsert(true),
)
acquired := err == nil && (res.ModifiedCount == 1 || res.UpsertedCount == 1)

조건과 쓰기가 원자적이기 때문에, 비어있는 리스를 차지하기 위한 경쟁에서 정확히 하나의 워커만이 이길 수 있습니다. 다른 모든 워커는 자신의 ModifiedCount가 0으로 반환되는 것을 보고 물러나야 한다는 것을 압니다. 동일한 형태가 Redis에서도 조건부 SET key value NX PX ttl를 사용하여 작동하는데, 여기서 NX는 비교(없는 경우에만)이고 PX는 하나의 명령으로 만료를 설정합니다.

하트비트로 리스를 활성 상태로 유지하기

긴 작업은 합리적인 리스 TTL보다 오래 지속되므로 홀더는 리스를 갱신해야 합니다. 하트비트는 TTL 내에서 실행되어 만료 시간을 연장하는 타이머이지만, 이 워커가 여전히 기록된 홀더인 경우에만 작동합니다. 이 마지막 조건은 중요합니다. 하트비트는 이 워커가 이미 잃어버린 리스를 절대 되살려서는 안 됩니다.

// Renew only if we are still the holder. If we lost the lease, this writes
// nothing and the ok flag tells us to stop working.
func heartbeat(ctx context.Context, coll *mongo.Collection, lockKey, workerID string, ttl time.Duration) (bool, error) {
	res, err := coll.UpdateOne(ctx,
		bson.M{"_id": lockKey, "holder": workerID},
		bson.M{"$set": bson.M{"expires_at": time.Now().Add(ttl)}},
	)
	if err != nil {
		return false, err
	}
	return res.ModifiedCount == 1, nil
}

하트비트 간격과 TTL 사이의 비율은 조정 손잡이이며, 이는 직접적인 트레이드오프 관계입니다. 간격을 TTL의 약 3분의 1로 설정하십시오. 이렇게 하면 리스가 실제로 만료되기 전에 한두 개의 하트비트가 일시적으로 실패할 여유가 생기므로, 잠깐의 네트워크 문제로 인해 다른 워커에게 리스가 넘어가지 않습니다. TTL이 짧으면 충돌한 홀더를 빠르게 복구하지만 일시적인 문제에 대한 허용 범위는 좁습니다. TTL이 길면 일시적인 문제에 대해서는 너그럽지만 실제 충돌 후에는 리스가 더 오래 묶여 있게 됩니다. 복구해야 하는 속도에 따라 TTL을 선택한 다음, 간격을 그 값의 3분의 1로 설정하십시오.

하트비트가 해결하지 못하는 실패 모드

여기에 임대 잠금(lease lock)에 대한 불편한 진실, 그리고 이것이 겉보기보다 보장하는 것이 적은 이유가 있습니다. 잠금 보유자(holder)가 충돌하는 대신 단순히 멈춘다고 가정해 봅시다. 예를 들어 긴 가비지 컬렉션 일시 중지, 스케줄러 기아 상태, 중단된 네트워크 호출 등이 있습니다. 하트비트가 도착하지 않았기 때문에 임대가 만료됩니다. 두 번째 워커가 합법적으로 임대를 획득하고 실행을 시작합니다. 그 후 첫 번째 워커가 깨어나 여전히 임대를 보유하고 있다고 믿으며, 다음 하트비트에서 진실을 발견하기 전에 한 번 더 쓰기를 수행합니다. 그 순간에는 두 워커가 모두 활성 상태가 됩니다.

어떤 타임아웃 조정으로도 이 문제를 제거할 수 없습니다. 이는 근본적인 문제입니다. 시간에 기반한 분산 잠금은 타임아웃보다 더 오래 일시 중지된 보유자에 의해 항상 위반될 수 있습니다. 왜냐하면 보유자는 "내가 일시 중지했다"와 "시간이 흐르지 않았다"의 차이를 구별할 수 없기 때문입니다. 할 수 있는 일은 임대를 유일한 방어선이 아니라 정확성을 위한 권고 사항으로 만드는 것입니다. 임대는 일반적인 경우를 단일 실행자(single-runner)로 저렴하게 유지합니다. 그 아래의 쓰기는 자체적으로 안전하게 만들어져, 드물게 발생하는 이중 실행자(double-runner) 순간에도 아무것도 손상시킬 수 없습니다.

이를 위한 표준 도구는 펜싱 토큰(fencing token)입니다. 이는 임대가 부여될 때마다 증가하는 숫자입니다. 워커는 모든 쓰기에 자신의 토큰을 첨부하고, 리소스는 자신이 본 가장 높은 토큰보다 오래된 토큰을 가진 모든 쓰기를 거부합니다. 일시 중지되었다가 깨어난 워커는 오래된(stale) 토큰을 가지고 있으므로, 그 지연된 쓰기는 거부됩니다. 멱등성 쓰기(idempotent writes)와 결합하여, 임대는 활성(liveness)과 성능을 처리하고 펜싱 토큰과 멱등성은 정확성을 처리합니다. 임대만으로 상호 배제(mutual exclusion)를 보장하도록 요구하지 마십시오. 네트워크상에서는 그것이 불가능하기 때문입니다.

깔끔하게 자신의 리스만 해제하기

깔끔하게 완료되면 TTL이 경과하기를 기다리는 대신 즉시 해제해야, 다음으로 예약된 실행이 자신과 무관한 만료를 기다리며 유휴 상태에 머무르지 않습니다. 해제는 하트비트와 동일한 가드를 가집니다. 즉, 여전히 홀더인 경우에만 리스를 삭제하여, 이미 리스를 잃은 워커가 다른 워커가 이제 합법적으로 보유한 리스를 삭제하지 않도록 합니다.

// Release only our own lease. Never delete a lease we no longer hold.
_, err := coll.DeleteOne(ctx, bson.M{"_id": lockKey, "holder": workerID})

해당 holder: workerID 조건은 장식이 아닙니다. 이 조건이 없으면 리스를 잃은 느린 워커가 빠져나갈 때 현재 홀더의 리스를 삭제할 수 있으며, 다시 두 개의 러너가 실행되게 됩니다. 획득(acquire), 하트비트(heartbeat), 해제(release) 등 리스의 모든 변경은 이를 수행하는 워커의 ID를 조건으로 합니다. 바로 그 단 하나의 원칙이 메커니즘을 정직하게 유지하는 것입니다.

이러한 것들이 필요 없는 경우

리스 락은 하나의 메커니즘이며, 필요 없는 메커니즘은 부담이 됩니다. 일부 관리형 작업 실행기처럼 스케줄러가 동시성 제한을 1로 두고 중복 없이 단일 인스턴스 실행을 이미 보장한다면, 이미 상호 배제가 적용되고 있으므로 리스를 추가하는 것은 중복입니다. 작업이 자연스럽게 멱등적이고 동시에 실행해도 안전하다면, 두 개의 복사본을 실행하는 것은 위험하기보다는 단지 낭비일 뿐이며, 리스는 정확성이 아닌 최적화입니다.

다음 세 가지 조건이 함께 충족될 때 리스 락을 사용하세요: 작업이 동시에 실행되어서는 안 되고, 스케줄러가 자체적으로 이를 보장할 수 없으며(재시도, 재배포 또는 여러 트리거가 중첩될 수 있기 때문), 작업의 비용이 많이 들거나 상태를 변경하여 중복 실행이 실질적인 피해를 주는 경우입니다. 이러한 조건이 충족되면, compare-and-swap 획득과 하트비트를 사용해 사람의 개입 없이도 충돌에서 살아남는 락을 얻을 수 있으며, 이를 펜싱 토큰 및 멱등적 쓰기와 결합하면 리스만으로는 보장할 수 없는 정확성을 확보할 수 있습니다.

관련 글

데이터베이스

Firestore의 MongoDB 호환성에서 `retryWrites=false`의 함정 ================================================================ Firestore를 MongoDB 호환 모드에서 사용하고 있다면 쓰기 작업이 때때로 실패하고 온라인에서 찾을 수 있는 유일한 "해결책"은 연결 문자열에 `retryWrites=false`를 추가하는 것뿐인 특이한 문제를 겪었을 수 있습니다. 이것이 문제를 해결하는 것처럼 보일 수 있지만, 데이터 손실 및 기타 문제로 이어질 수 있는 함정입니다. 이러한 현상이 발생하는 이유와 올바른 해결책이 무엇인지 알아보겠습니다. ## 쓰기 작업이 실패하는 이유 Firestore는 MongoDB를 에뮬레이션할 때 MongoDB의 재시도 가능 쓰기(retryable writes) 기능을 완벽하게 지원하지 않습니다. 쓰기 작업이 Firestore로 전송될 때 일시적인 네트워크 오류나 서버 측의 일시적인 문제가 발생하면 MongoDB 드라이버는 자동으로 쓰기 재전송을 시도할 수 있습니다. 하지만 Firestore의 에뮬레이션 레이어는 이 두 번째 시도를 올바르게 처리하지 못할 수 있으며, 특히 첫 번째 시도가 이미 처리 중인 경우 더욱 그렇습니다. 이로 인해 드라이버는 쓰기 작업이 실패했다고 생각하는 오류 상태가 발생할 수 있으며, 실제로는 결국 성공할 수도 있습니다. ## `retryWrites=false` "해결책" 연결 문자열에 `retryWrites=false`를 추가하면 MongoDB 드라이버에게 첫 번째 시도 후에 바로 포기하라고 지시합니다. 일시적인 오류가 발생하면 쓰기 작업은 즉시 실패하고 애플리케이션은 오류를 보게 됩니다. 더 이상 혼란스러운 재시도 관련 오류가 발생하지 않기 때문에 이것이 문제를 해결하는 것처럼 *보입니다*. 쓰기 작업은 첫 시도에 성공하거나 깔끔하게 실패합니다. ## 진짜 문제: 데이터 손실 함정은 재시도를 비활성화함으로써 이제 일시적인 실패를 직접 처리해야 한다는 점입니다. 그렇게 하지 않으면, 애플리케이션은 순간적인 네트워크 문제만으로도 중요한 데이터를 저장하지 못할 수 있습니다. 사용자는 데이터가 저장되었다고 생각할 수 있지만, 실제로는 쓰기 작업이 조용히 실패했을 수 있습니다(오류를 처리하지 않는다면 그다지 조용하지 않을 수도 있습니다). 이는 데이터 신뢰성 측면에서 중대한 후퇴입니다. ## 올바른 해결책 중요한 신뢰성 기능을 비활성화하는 대신, 올바른 접근 방식은 Firestore의 MongoDB 호환성을 위해 환경이 올바르게 설정되었는지 확인하는 것입니다. * **1. 공식적으로 지원되는 드라이버 및 버전 사용:** Google Cloud의 문서를 확인하여 Firestore에서 완전히 테스트되고 지원되는 MongoDB 드라이버 및 버전 목록을 확인하십시오. 지원되지 않는 버전을 사용하면 예측할 수 없는 동작이 발생할 수 있습니다. * **2. 사용자 지정 재시도 로직 구현:** 문제를 일으키는 드라이버 구성을 꼭 사용해야 하는 경우, 재시도를 전역적으로 비활성화하지 마십시오. 대신, 쓰기 실패와 관련된 특정 오류 코드를 포착하여 애플리케이션 코드에 자체 재시도 로직을 구현하십시오. 이를 통해 재시도 횟수와 백오프 전략(예: 지수 백오프)을 제어할 수 있습니다. * **3. 문제 보고:** Firestore의 MongoDB 호환성 레이어에서 버그를 발견했다고 생각되면 Google Cloud 지원팀에 보고하십시오. 드라이버 버전, 연결 문자열(자격 증명 제외), 정확한 오류 메시지에 대한 자세한 정보를 제공하십시오. 이는 모든 사용자를 위해 서비스를 개선하는 데 도움이 됩니다. 요약하자면, `retryWrites=false`는 해결책이 아닙니다. 이는 더 깊은 문제를 숨기고 데이터 손실의 위험을 초래하는 임시방편입니다. 항상 지원되는 구성을 사용하고 애플리케이션에 강력한 오류 처리 및 재시도 메커니즘을 구축하는 것을 우선시해야 합니다.

Firestore는 MongoDB 와이어 프로토콜을 지원하지만 재시도 가능 쓰기는 거부합니다. 드라이버의 기본 설정이 on인 경우, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.

5분 읽기 firestoremongodbgoidempotencydatabases
백엔드

백엔드를 Rust에서 Go로 이전한 이유, 그리고 빌드 시간이 결정적이었던 까닭 ================================================================ 저는 Rust를 사랑합니다. 정말입니다. 훌륭한 커뮤니티를 가진 환상적인 언어이며, 대부분의 개인 프로젝트에서 제가 선택하는 언어입니다. 하지만 최근 한 백엔드 프로젝트에서는 Rust에서 Go로 이전하는 어려운 결정을 내렸습니다. 주된 이유는요? 바로 빌드 시간이었습니다. ## Rust 경험: 강력하지만 컴파일이 느림 저희 백엔드 서비스는 Rust로 작성된 작은 모놀리스로 시작했습니다. 초기 개발은 순조로웠습니다. Rust의 타입 시스템과 소유권 모델은 컴파일 시간에 많은 버그를 잡아주어 저희에게 큰 자신감을 주었습니다. `serde`, `tokio`, `actix-web`과 같은 크레이트가 있는 생태계는 함께 작업하기에 즐거웠습니다. 하지만 프로젝트가 커지면서 컴파일 시간도 늘어났습니다. 개발자 머신에서 클린 빌드를 하는 데 몇 분이 걸렸습니다. 증분 빌드는 더 빠르긴 했지만, 점차 느리게 느껴지기 시작했습니다. 이것은 사소한 불편함을 넘어, 개발 속도를 실질적으로 저해하고 있었습니다. ## CI/CD 병목 현상 하지만 진짜 고통스러운 지점은 CI/CD 파이프라인에 있었습니다. 모든 풀 리퀘스트는 전체 릴리스 빌드와 포괄적인 테스트 스위트 실행을 포함한 일련의 검사를 트리거했습니다. 다음은 CI에서의 빌드 시간을 간략하게 살펴본 것입니다: | 단계 | Rust 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~1분 | | 디버그 빌드 (테스트용) | ~5-7분 | | 릴리스 빌드 (배포용) | ~10-15분 | | **총 파이프라인 시간** | **~20-25분** | 간단한 변경에 대한 25분의 피드백 루프는 받아들일 수 없었습니다. 저희는 `sccache`, Docker 레이어 캐싱, 빌드와 테스트 작업 분리 등 다양한 최적화를 시도했습니다. 이러한 방법들이 도움이 되긴 했지만, 몇 분을 단축하는 데 그쳤습니다. 핵심 문제는 그대로였습니다. Rust 컴파일러는 많은 작업을 수행하고(안전성 측면에서는 훌륭하지만!), 시간이 걸린다는 점입니다. ## Go의 등장: 실용적인 선택 저희는 실험을 해보기로 결정했습니다. 서비스의 핵심적이고 독립적인 부분을 가져와 Go로 다시 작성했습니다. 그 경험은... 달랐습니다. * **단순성:** Go는 훨씬 더 단순한 언어입니다. Rust의 강력한 추상화(트레이트나 고급 열거형 등)는 잃었지만, 코드는 놀라울 정도로 작성하고 읽기 쉬웠습니다. * **동시성:** Go의 고루틴과 채널은 동시성 처리를 쉽게 만들어 주어, 저희 백엔드 요구사항에 아주 적합했습니다. * **컴파일러:** 이것이 바로 게임 체인저였습니다. Go 컴파일러는 *빠릅니다*. 눈부시게 빠릅니다. 서비스의 상당 부분을 다시 작성한 후, 동일한 CI 파이프라인을 실행했습니다. 결과는 놀라웠습니다. | 단계 | Go 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~30초 | | 빌드 및 테스트 | ~1-2분 | | **총 파이프라인 시간** | **~2-3분** | 저희는 25분 걸리던 파이프라인을 3분짜리로 단축했습니다. 이것은 단지 점진적인 개선이 아니라, 개발 워크플로우에 대한 근본적인 변화였습니다. 개발자들은 거의 즉시 피드백을 받을 수 있게 되었습니다. 코드 병합이 더 빨라졌습니다. 핫픽스 배포가 사소한 일이 되었습니다. ## 트레이드오프와 결론 이전 과정이 순탄했냐고요? 아닙니다. 저희는 Rust의 컴파일 타임 보장을 포기했고, 이는 테스트와 코드 리뷰에 더 부지런해야 한다는 것을 의미했습니다. 제네릭의 부재(초기 재작성 시점)는 일부 코드를 더 장황하게 만들었습니다. Go의 오류 처리는 단순하지만, Rust의 `Result`와 `?` 연산자보다 덜 견고하게 느껴집니다. 하지만 개발 속도와 빠른 피드백 루프가 중요한 웹 백엔드라는 저희의 특정 사용 사례에서는 그 트레이드오프가 가치가 있었습니다. 빌드 시간의 극적인 감소는 개발자 생산성과 행복도 증가로 바로 이어졌습니다. 저는 여전히 Rust를 사랑하며, 성능과 안전성 보장이 가장 중요하고 컴파일 시간이 덜 중요한 프로젝트에서는 계속해서 사용할 것입니다. 하지만 이 경험은 때로는 작업에 가장 '최고의' 도구는 방해하지 않고 결과물을 내놓게 해주는 도구라는 것을 강력하게 상기시켜 주었습니다. 저희에게 그 도구는 Go였습니다.

Rust는 우리에게 거의 필요하지 않았던 안전성과 매일 싸워야 했던 컴파일 루프를 주었습니다. 한 서비스를 Go로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.

7분 읽기 gorustmigrationbuild-timedeveloper-experience