데이터베이스

MongoDB 다중 문서 트랜잭션: 언제 사용하지 말아야 하는가

다중 문서 트랜잭션은 안전한 기본값처럼 보이지만, 커밋 모호성과 PSA 가용성 함정으로 인해 비용이 많이 듭니다. 이를 건너뛰어야 하는 경우는 다음과 같습니다.

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

MongoDB는 4.0에서 다중 문서 트랜잭션을 추가했으며, 이 기능이 등장하자마자 대부분의 팀은 관계형 데이터베이스에서 하던 방식 그대로 이 기능을 사용하려 합니다. 이러한 본능은 대개 잘못된 것입니다. 트랜잭션은 그 자체만으로 쓰기 작업을 안전하게 만들지 않으며, 연산을 멱등(idempotent)하게 만들어야 할 의무를 없애주지도 않습니다. 그리고 일반적인 복제본 세트(replica set) 토폴로지에서는 노드가 다운되었을 때 바로 그 순간에 데이터베이스가 쓰기를 거부하게 만들 수 있습니다. 이 게시물에서는 한 팀이 트랜잭션 중심의 설계를 포기하게 만든 세 가지 함정과, 이를 대부분 대체한 단일 문서 패턴에 대해 다룹니다.

다중 문서 트랜잭션이 실제로 보장하는 것

MongoDB 트랜잭션은 여러 문서에 걸쳐 전부 아니면 전무(all-or-nothing) 방식의 쓰기를 제공합니다. 세션을 열고, 트랜잭션을 시작하고, 해당 세션에 대해 쓰기를 실행한 다음 커밋합니다. 쓰기 중 하나라도 실패하거나 사용자가 중단하면, 어떤 쓰기도 보이지 않습니다. 격리 수준은 스냅샷 수준이므로, 동시 읽기 작업은 커밋된 변경 사항 전체를 보거나 아무것도 보지 못합니다.

이는 실질적인 보장이며, 이러한 보장이 필요한 문제들이 있습니다. 문제는 그 보장이 보기보다 좁고, 비용은 더 넓다는 것입니다. 트랜잭션은 데이터베이스에 부분적인 쓰기가 적용되는 것을 방지합니다. 클라이언트가 커밋을 다시 보내는 경우, 복제본 세트가 과반수를 형성할 수 없는 경우, 또는 다중 문서 쓰기가 부하 상태에서 생성하는 잠금 경합으로부터는 보호하지 않습니다. 이 세 가지 격차가 바로 그 설계가 무너지는 지점입니다.

다음은 모든 사람이 처음에 작성하는 형태입니다.

session, err := client.StartSession()
if err != nil {
	return err
}
defer session.EndSession(ctx)

_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
	if err := debit(sc, from, amount); err != nil {
		return nil, err
	}
	if err := credit(sc, to, amount); err != nil {
		return nil, err
	}
	return nil, nil
})

깔끔하게 읽히고 데모도 잘 됩니다. 실패 모드는 프로덕션에서만 나타납니다.

UnknownTransactionCommitResult 문제

첫 번째 함정은 사람들이 테스트에서는 절대 발견하지 못하는 것입니다. 커밋을 호출하면 드라이버는 프라이머리에 명령을 보내고 확인 응답을 기다립니다. 쓰기가 영구적으로 저장된 후, 하지만 확인 응답이 사용자에게 도달하기 전에 네트워크 연결이 끊어지거나 프라이머리가 강등되면, 드라이버는 커밋이 성공했는지 알 수 없습니다. 이것을 UnknownTransactionCommitResult 레이블이 있는 오류로 표시합니다.

공식적인 지침은 커밋을 재시도하는 것이며, 드라이버의 WithTransaction 헬퍼가 이 작업을 대신 해줍니다. 문제가 해결된 것처럼 들리지만, 커밋을 재시도하는 것이 무엇을 의미하는지 살펴보십시오. 결과를 알 수 없는 작업을 다시 보내는 것입니다. 만약 첫 번째 커밋이 실제로 성공했다면, 재시도는 이미 커밋된 트랜잭션을 커밋하게 되는데, MongoDB는 이를 no-op으로 처리하므로 그 특정 경우는 안전합니다. 위험은 한 단계 위에 있습니다. 만약 클라이언트 타임아웃, 큐 재전송, 사용자 더블 클릭 등으로 인해 재시도될 수 있는 요청에 의해 전체 트랜잭션이 구동된다면, 전체 차변 및 대변 작업이 완전히 새로운 트랜잭션으로 두 번째 실행될 수 있습니다. 트랜잭션 경계는 그것을 막지 못합니다. 각 실행이 원자적이라는 것만 보장할 뿐, 논리적 작업이 한 번만 실행된다는 것을 보장하지는 않습니다.

// Retrying the commit is safe. Retrying the whole operation is not,
// unless debit() and credit() are idempotent by construction.
for {
	err := doTransfer(ctx, from, to, amount, transferID)
	if err == nil {
		break
	}
	if hasErrorLabel(err, "TransientTransactionError") {
		continue // safe to replay the transaction
	}
	if hasErrorLabel(err, "UnknownTransactionCommitResult") {
		continue // safe to retry the commit
	}
	return err
}

TransientTransactionError 레이블은 전체 트랜잭션을 안전하게 재실행할 수 있는 실패를 표시하고, UnknownTransactionCommitResult는 재시도할 수 있는 커밋을 표시합니다. 두 재시도 루프 모두 기본 작업이 반복하기에 안전하다고 가정합니다. 그 가정은 여러분이 구축해야 하는 것입니다. 트랜잭션은 멱등성(idempotency)을 공짜로 제공하지 않습니다. 전송이 정확히 한 번만 적용되도록 하려면, 재실행이 아무 작업도 하지 않는(no-op) 작업이 되도록 트랜잭션 내에서 기록하고 확인하는 안정적인 멱등성 키(idempotency key), 보통 transferID가 여전히 필요합니다. 일단 그 키를 가지게 되면, 트랜잭션이 제공하던 이점의 대부분은 이미 그 키에 의해 처리됩니다.

w=majority를 사용하는 PSA의 가용성 함정

두 번째 함정은 구조적인 것으로, 정확성이 아닌 가용성에 영향을 미칩니다. 트랜잭션은 기본적으로 w=majority 쓰기 컨선(write concern)으로 커밋되며, 이는 사용자가 원하는 방식입니다. 더 약한 컨선으로 커밋된 트랜잭션은 장애 조치(failover) 시 롤백될 수 있어 목적에 어긋나기 때문입니다. 문제는 Primary-Secondary-Arbiter 토폴로지에서 과반수(majority)가 무엇을 의미하는가입니다.

PSA는 아비터(arbiter)가 저렴하기 때문에 널리 사용되는 3노드 구성입니다. 아비터는 선거에는 투표하지만 데이터는 보유하지 않으므로, 두 개의 데이터 보유 노드에 대한 비용만 지불하고도 장애 조치 기능을 얻을 수 있습니다. 함정은 아비터가 쓰기를 승인할 수 없다는 점입니다. 3개 멤버로 구성된 세트에서 과반수는 2개의 노드이며, 3개의 멤버 중 2개만이 데이터를 가지고 있습니다. 데이터 보유 노드 중 하나라도 다운되면, 과반수 쓰기를 승인할 수 있는 노드 하나와 그럴 수 없는 노드 하나만 남게 됩니다. 이 세트는 일반적인 삽입(insert)에 대해서는 쓰기 가능한 상태를 유지하지만, 모든 트랜잭션 커밋에 해당하는 w=majority 쓰기는 더 이상 과반수에 도달할 수 없습니다. 두 번째 데이터 보유 노드가 복구될 때까지 커밋은 블로킹되거나 시간 초과됩니다.

토폴로지 데이터 노드 과반수 w=majority에 대해 데이터 노드 1개 장애 감내
PSA (프라이머리-세컨더리-아비터) 2 2 아니요
PSS (프라이머리-세컨더리-세컨더리) 3 2

따라서 노드 장애를 감내하는 것처럼 보였던 토폴로지는 일반적인 쓰기에 대해서는 장애를 감내하지만, 트랜잭션에 대해서는 그렇지 않습니다. 이러한 사실은 복제본 세트(replica set)를 구축하여 대비하고자 했던 바로 그 장애 상황에서 발견하게 됩니다. 새벽 3시에 세컨더리 노드가 다운되고, 일반적인 트래픽은 계속 흐르지만 모든 트랜잭션 쓰기 경로는 중단됩니다. 해결책은 세 번째 데이터 보유 노드 비용이 드는 PSS로 구성하거나, 해당 경로에서 과반수 승인이 필요한 다중 문서 커밋을 아예 요구하지 않는 것입니다. 만약 거기서 트랜잭션을 사용하지 않았다면, w=1을 사용한 단일 문서 쓰기는 계속해서 서비스를 제공했을 것입니다.

성능 및 잠금 비용

세 번째 함정은 더 조용하며 지연 시간으로 나타납니다. 트랜잭션은 전체 기간 동안 리소스를 보유합니다. 스냅샷을 고정하므로 스토리지 엔진은 트랜잭션이 지속되는 동안 이전 버전의 문서를 읽을 수 있도록 유지해야 하며, 동일한 문서에 대한 다른 작성자가 대기하는 문서 수준 잠금을 사용합니다. 경합이 발생하면 핫 문서를 건드리는 트랜잭션이 서로 직렬화되고, 느린 트랜잭션은 해당 문서를 건드리는 모든 작성자를 더 느리게 만듭니다.

MongoDB는 또한 트랜잭션을 작게 유지하도록 유도하는 강력한 제한을 둡니다. 기본 트랜잭션 수명은 60초이며, 이 시간이 지나면 서버가 트랜잭션을 중단합니다. 16MB의 oplog 변경 사항을 초과하여 커지는 트랜잭션은 내부적으로 분할되어야 하며 더 많은 비용이 듭니다. 이들 중 어느 것도 치명적이지는 않지만, 종합해 보면 트랜잭션은 대량 작업을 수행하는 곳이 아니며, 느린 외부 호출이나 대규모 배치에 걸친 트랜잭션은 설계 실수라는 것을 의미합니다. 건강한 트랜잭션은 밀리초 동안 유지되는 몇 개의 문서에 대한 몇 번의 쓰기입니다. 여러분의 트랜잭션이 그렇지 않은 순간, 트랜잭션은 튜닝해야 할 도구가 아니라 잘못된 도구입니다.

대부분의 트랜잭션을 대체하는 패턴

사람들이 트랜잭션을 사용하여 보호하려는 대부분의 불변성은 실제로는 여러 문서에 걸쳐 있지 않습니다. 단일 문서의 여러 필드에 걸쳐 있거나, 단일 문서로 모델링할 수 있는 단일 논리적 사실입니다. MongoDB는 트랜잭션 없이도 단일 문서를 원자적으로 업데이트하며, 그 원자적 단일 문서 업데이트와 멱등성 upsert를 더하면 놀라울 정도로 많은 사례를 처리할 수 있습니다.

교과서적인 두 문서 트랜잭션처럼 보이는 이체 예시를 들어보겠습니다. 만약 잔액이 통화 맵으로 하나의 계정 문서에 존재한다면, 전체 작업은 단일 원자적 업데이트가 됩니다. 만약 잔액이 별도의 문서에 있어야 한다면, 이체 자체를 결정적 ID를 가진 문서로 모델링하고, 각 측이 해당 ID에 대해 멱등적으로 적용하도록 하십시오.

// One document, one atomic update. No transaction, no majority commit
// required, and it stays writable when a data node is down.
res, err := accounts.UpdateOne(ctx,
	bson.M{"_id": from, "balance": bson.M{"$gte": amount}},
	bson.M{"$inc": bson.M{"balance": -amount}},
)
if err != nil {
	return err
}
if res.ModifiedCount == 0 {
	return ErrInsufficientFunds // the guard failed atomically
}

필터가 불변성을 유지합니다. balance >= amount 조건과 $inc는 서버에서 단일 문서에 대해 함께 평가되므로, 동시 쓰기 작업자가 확인과 감소 작업 사이에 끼어들 수 있는 시간적 간격이 없습니다. 경쟁하는 두 개의 출금은 둘 다 가드를 통과할 수 없는데, 두 번째 출금은 이미 감소된 잔액을 보기 때문입니다. 이것은 커밋 모호성, 과반수 요구사항, 두 문서에 걸쳐 유지되는 잠금 없이, 트랜잭션이 단일 문서 사례에 대해 제공하는 것과 동일한 안전성입니다.

작업이 정확히 한 번만 존재해야 하는 레코드를 생성하는 경우, 재시도 시 중복 생성 대신 덮어쓰도록 결정적 id와 upsert를 사용하십시오.

// Idempotent by id. First call inserts, every retry overwrites identical
// data, the row count never grows. Safe to replay from any retry loop.
id := TransferID(from, to, requestID)

_, err := transfers.UpdateByID(ctx, id,
	bson.M{"$setOnInsert": bson.M{
		"from": from, "to": to, "amount": amount, "at": time.Now(),
	}},
	options.Update().SetUpsert(true),
)

이는 멱등성 upsert를 위한 결정적 ULID의 기반이 되는 아이디어와 동일합니다. id는 작업을 식별하는 것에서 파생되므로, 두 번째 시도는 동일한 id를 대상으로 하여 no-op이 됩니다. 원자적 보호 업데이트와 멱등성 upsert를 통해 트랜잭션은 대부분의 경로에서 사라지며, 위에서 언급한 세 가지 함정도 함께 사라집니다. 이것이 바로 트랜잭션 우선으로 시작한 팀이 여러 사고를 통해 트랜잭션의 실제 비용을 깨닫게 되면 비트랜잭션 설계로 되돌아가는 경향이 있는 이유입니다.

트랜잭션이 실제로 필요한 경우

이 모든 것이 트랜잭션이 결코 옳지 않다는 의미는 아닙니다. 정당한 사례는 하나로 병합할 수 없는 문서들에 걸쳐 있는 진정한 '전부 아니면 전무(all-or-nothing)' 불변성이며, 이 제약 조건을 단일 문서 가드나 멱등성 키로는 표현할 수 없는 경우입니다. 독립적으로 쿼리되는 두 컬렉션 사이에서 항목을 이동할 때, 읽는 이(reader)가 해당 항목을 양쪽 모두에서 보거나 양쪽 모두에서 보지 못하는 상황이 절대 발생해서는 안 되는 경우가 실제 예입니다. 별도의 문서에 있는 차변 라인과 대변 라인이 모두 존재하거나 모두 존재하지 않아야 하고, 이 쌍이 너무 자주 독립적으로 쿼리되어 단일 문서로 통합할 수 없는 부기 원장도 마찬가지입니다.

테스트는 간단합니다. 불변성이 정말로 두 개 이상의 문서가 함께 변경되어야 하는지, 그리고 읽는 이(reader)가 규칙을 위반하는 방식으로 중간 상태를 관찰할 수 있는지 자문해 보십시오. 두 질문에 대한 답이 모두 '예'라면 트랜잭션이 올바른 도구이며 그 비용을 의도적으로 받아들여야 합니다. 이는 노드 손실에도 과반수 커밋이 유지되도록 PSS 토폴로지를 선택하고, 트랜잭션을 작고 빠르게 유지하며, 재실행 시 이중으로 적용되지 않도록 둘러싸는 작업을 멱등성으로 만드는 것을 의미합니다. 둘 중 하나라도 답이 '아니요'라면, 여러분은 데이터 모델에 대한 고민을 피하고자 트랜잭션을 사용하려는 것이며, 그 모델에는 더 간단한 해답이 있습니다.

트랜잭션을 사용하기 전의 체크리스트

  • 불변성은 단일 문서의 여러 필드뿐만 아니라, 하나로 모델링할 수 없는 여러 문서에 진정으로 걸쳐 있습니다.
  • 동시성 리더가 실제로 깨진 중간 상태를 관찰할 수 있으므로, 문서 간의 원자성은 단순히 깔끔하게 정리하는 것 이상으로 필수적입니다.
  • 커밋이 UnknownTransactionCommitResult를 반환할 수 있고 전체 요청을 트랜잭션 위에서 재시도할 수 있으므로, 감싸는 작업은 안정적인 ID를 키로 사용하여 어쨌든 멱등성을 가집니다.
  • 복제본 세트가 PSA가 아닌 PSS이므로, 데이터 보유 노드 하나가 다운된 상태에서도 w=majority 커밋은 여전히 성공합니다.
  • 트랜잭션은 작고 짧으며, 몇 개의 문서를 밀리초 단위로 유지하고, 내부에 느린 외부 호출이나 대량 쓰기가 없습니다.

이 다섯 가지를 모두 충족하면 트랜잭션은 다른 어떤 것으로도 할 수 없는 작업을 수행하는 것이며, 그 비용은 당신이 선택한 대가입니다. 처음 두 가지를 충족하지 못하면 단일 문서 원자적 업데이트와 멱등성 있는 업서트(upsert)가 더 높은 가용성과 더 적은 경합으로 동일한 불변성을 제공합니다. 기본은 더 간단한 설계여야 하며, 트랜잭션은 한 번에 하나의 경로씩 정당화할 수 있는 예외가 되어야 합니다.

관련 글