데이터베이스

멱등성 업서트를 위한 결정적 ULID

데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등적이어야 합니다. 콘텐츠 기반 ID는 이를 간단하게 만듭니다. 여기에 그 유도 과정과 안전성을 유지하는 한 가지 불변식이 있습니다.

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

데이터베이스가 자동 쓰기 재시도를 금지할 때, 다른 어떤 것도 중복을 제거해주지 않기 때문에 보내는 모든 쓰기는 그 자체로 멱등성을 가져야 합니다. 이 게시물에서 다루는 기술은 결정론적 ULID입니다. 즉, 임의의 소스가 아닌, 문서를 식별하는 내용에서 파생된 문서 ID입니다. 도입하는 데 비용이 거의 들지 않으며, 구조적으로 재시도를 안전하게 만들고, 절대 어겨서는 안 되는 단 하나의 규칙이 따릅니다. 여기서는 전체 파생 과정, 이것이 방지하는 실패, 정확성을 유지하는 불변성, 그리고 적용되지 않는 경우를 다룹니다.

ID 파생 파이프라인 다이어그램

수동 재시도 시 랜덤 ID가 위험한 이유

ULID는 128비트 식별자입니다. 상위 비트에는 타임스탬프가, 하위 비트에는 랜덤값이 들어갑니다. 타임스탬프 접두사는 ULID가 생성 시간순으로 대략 정렬된다는 것을 의미하며, 이는 기본 키에 정말 유용합니다. 랜덤 접미사는 각 ULID를 고유하게 만드는 요소이며, 동시에 쓰기를 수동으로 재시도하는 순간 위험을 초래하는 요소이기도 합니다.

새로운 랜덤 ID를 사용하는 삽입 작업을 상상해 보세요. 쓰기 요청이 서비스를 떠나 데이터베이스에 적용된 후, 돌아오는 과정에서 확인 응답이 손실됩니다. 코드는 쓰기 성공 여부를 알 수 없으므로, 올바르게도, 재시도를 합니다. 새로운 ULID를 생성했기 때문에 재시도 요청에는 새로운 랜덤 ID가 포함됩니다. 이제 동일한 논리적 대상을 설명하는 두 개의 문서가 서로 다른 ID로 존재하게 되며, 시스템 내에서는 이들이 중복이라는 것을 알 수 없습니다. 모든 것을 올바르게 처리했지만 데이터는 손상되었습니다.

실제 MongoDB에서는 retryable writes 기능이 이 문제를 덮어줍니다. 드라이버가 트랜잭션 번호를 첨부하고 서버가 중복을 제거하므로 재시도는 안전합니다. 하지만 일부 엔진은 이 메커니즘을 지원하지 않고 retryWrites=false를 요구하며, 이 시점부터는 안전망이 사라지고 책임은 사용자에게 있습니다. 서버가 중복 제거를 해주던 때에는 괜찮았던 랜덤 ID가, 사용자가 직접 재시도하는 순간 부채가 됩니다.

id를 파생시키면 모호성이 완전히 제거됩니다

해결책은 무작위성으로 id를 생성하는 것을 멈추고, 문서를 식별하는 필드로부터 id를 파생시키는 것입니다. 동일한 논리적 문서가 항상 동일한 id를 생성한다면, 재시도된 쓰기는 동일한 id를 대상으로 하게 되고, upsert는 두 번째 행을 만드는 대신 무해한 덮어쓰기로 전환됩니다.

다국어 블로그의 경우, 게시물은 slug, 언어, 그리고 발행 시점으로 고유하게 식별됩니다. 따라서 우리는 바로 그 값들로부터 ULID를 만듭니다:

// PostID derives a stable ULID from the fields that identify a post.
// The same (slug, lang, publishedAt) always yields the same id, so an
// upsert is a no-op on every retry instead of a duplicate insert.
func PostID(slug, lang string, publishedAt int64) string {
	sum := sha256.Sum256([]byte(slug + ":" + lang))
	return buildULID(uint64(publishedAt), sum[:10])
}

여기서 두 가지 설계 결정이 핵심적인 역할을 합니다. ULID의 타임스탬프 부분은 쓰기 시점의 벽시계 시간(wall clock)이 아닌 publishedAt에서 가져오므로, 내일 동일한 게시물을 다시 수집하더라도 id는 변경되지 않습니다. 무작위 부분은 식별 필드들의 SHA-256 해시의 첫 10바이트로 대체되므로, 결정적이면서도 단일 인덱스 범위에서의 핫스팟(hot-spotting)을 피하기 위해 키스페이스(keyspace) 전반에 id를 분산시킵니다. 그 결과, 일반적인 ULID처럼 보이고 정렬되며, 여전히 의미 있는 타임스탬프 접두사를 가지면서도 콘텐츠만으로 재현 가능한 id가 만들어집니다.

이 id를 가지고, 쓰기는 이 id를 키로 사용하는 upsert가 됩니다:

// First call inserts, every retry overwrites identical data.
// Row count never grows, no server-side dedup required.
_, err := coll.UpdateByID(ctx, PostID(slug, lang, publishedAt),
	bson.M{"$set": doc},
	options.Update().SetUpsert(true),
)

이것을 한 번 실행하면 삽입됩니다. 백 번 실행해도 데이터베이스에는 정확히 하나의 문서만 존재하는데, 이는 첫 번째 호출 이후의 모든 호출이 동일한 필드를 동일한 id에 쓰기 때문입니다. 이 작업은 조정(coordination), 트랜잭션, 중복 제거 테이블 없이 멱등성을 가집니다.

멱등성은 단일 호출이 아닌 전체 파이프라인의 속성입니다

업서트(upsert)에서 멈추고 성공했다고 선언하고 싶을 수 있지만, 멱등성은 종단 간(end to end)에 걸쳐 유지되어야 하며 그렇지 않으면 전혀 유지되지 않는 것입니다. ID가 안정적이더라도 문서 본문에 새로 생성된 타임스탬프나 임의의 필드가 포함되어 있다면, 두 번의 실행은 동일한 ID에 대해 두 개의 다른 본문을 생성합니다. 중복된 행은 생기지 않겠지만, 불필요한 쓰기가 발생하고 파이프라인이 실행될 때마다 문서가 변경됩니다. 이는 변경 피드를 어지럽히고, 캐시를 무효화하며, 실제 편집과 아무 작업도 하지 않은 경우(no-op)를 구별하는 것을 불가능하게 만듭니다.

따라서 이러한 원칙은 페이로드까지 확장됩니다. 식별 콘텐츠에서 파생된 문서 내의 모든 것은 그 자체로 결정적이어야 합니다. 소스의 콘텐츠 해시가 좋은 예입니다. 입력으로부터 해시를 계산하여 문서에 저장하고, 저장된 해시가 들어오는 해시와 일치하면 쓰기 작업을 완전히 건너뜁니다. 이제 변경되지 않은 게시물을 다시 실행하는 것은 안전한 덮어쓰기일 뿐만 아니라, 아예 쓰기 작업이 없는 것입니다.

// Skip the write when nothing changed. Idempotent and cheap.
if existing != nil && existing.SourceHash == incoming.SourceHash {
	return nil // unchanged, nothing to do
}

이를 올바르게 유지하는 정신 모델은 다음과 같습니다. 동일한 입력, 동일한 ID, 동일한 본문, 그리고 이상적으로는 입력이 변경되지 않았을 때 쓰기 작업 없음. 각 계층은 그 위의 계층을 강화합니다.

반드시 지켜야 할 단 하나의 불변성

id는 publishedAt에서 파생됩니다. 이는 publishedAt가 문서의 수명 동안 불변해야 함을 의미합니다. 이것은 전체 체계가 의존하는 단 하나의 규칙이며, 이를 어기는 것은 별도의 경고가 필요할 만큼 미묘한 문제입니다.

게시물이 발행되었고, 그 id가 발행일로부터 계산되어 데이터베이스에 저장되어 있다고 가정해 봅시다. 나중에 누군가 소스에서 발행일을 수정합니다. 다음 수집 시 파생 입력이 변경되었기 때문에 새로운 id가 계산됩니다. 새로운 id를 사용한 업서트(upsert)는 완전히 새로운 문서를 삽입하고, 이전 id 하의 기존 문서는 이제 어떤 코드도 다시는 업데이트하거나 삭제하지 않을 고아(orphan)가 됩니다. 게시물을 덮어쓴 것이 아닙니다. 포크(fork)한 것입니다.

작성자가 기억할 것이라고 믿기보다는 명시적으로 이를 방지해야 합니다. 수집 시 식별 필드로 기존 문서를 조회하고, id를 다시 계산한 후, 저장된 id와 새로 계산된 id가 일치하지 않으면 쓰기를 중단하고 큰 오류를 발생시키십시오.

// If publishedAt changed, the derived id changed, and a blind upsert would
// orphan the old document. Refuse to proceed.
if existing != nil && existing.ID != PostID(slug, lang, publishedAt) {
	return fmt.Errorf("publishedAt is immutable for %s/%s: changing it orphans the old document", slug, lang)
}

본문은 id의 일부가 아니므로 언제든지 수정해도 괜찮습니다. 파생에 사용되는 필드만 동결됩니다. 작성 문서에 그 경계를 명시하여 이 규칙이 사람들이 우연히 발견하는 함정이 아니라 알려진 제약 조건이 되도록 하십시오.

결정론적 ID가 잘못된 선택일 때

이 기술은 보편적이지 않으며, 적합하지 않은 곳에 사용하면 그 자체로 문제를 일으킵니다. 이 기술은 문서가 자연스러운 정체성, 즉 어떤 문서인지를 진정으로 정의하는 필드 집합을 가질 때 효과적입니다. 블로그 게시물에는 slug, language, date가 있고, 사용자 계정에는 이메일이나 외부 ID가 있습니다.

문서에 자연 키가 없고 각 문서가 고유한 이벤트일 때는 효과가 없습니다. 독립적인 발생의 추가 전용 로그, 클릭 스트림, 동일하게 보이는 두 항목이 합법적으로 두 개의 다른 것인 작업 큐 등, 이 모든 것들은 두 번째 동일한 이벤트가 첫 번째 이벤트를 덮어쓰는 것을 원하지 않기 때문에 고유한 무작위 ID를 필요로 합니다. 이러한 경우에는 일반적인 무작위 ULID를 사용하고, 재시도 안전성이 필요하다면 기본 ID에 내장하는 대신 요청에 포함된 멱등성 키에서 가져오세요.

테스트는 단 하나의 질문입니다. 두 쓰기가 동일한 내용을 담고 있다면, 두 번째가 첫 번째를 대체해야 할까요, 아니면 그 옆에 나란히 있어야 할까요? 대체해야 한다면 ID를 파생시키세요. 나란히 있어야 한다면 무작위로 유지하세요. 전체 시스템에 대해 한 번이 아니라 컬렉션별로 이 질문에 답하세요.

노력의 대가

해시와 헬퍼 함수 하나만 있으면 되므로 노력은 거의 들지 않으며, 그 대가로 분산 시스템 버그의 한 부류 전체가 아예 발생할 수 없게 됩니다. 재시도는 동일한 ID로 수렴되기 때문에 안전합니다. 본문과 해시가 안정적이기 때문에 변경되지 않은 콘텐츠를 다시 수집하는 것은 비용이 적게 드는 무연산(no-op)이 됩니다. 또한, 자동 쓰기 재시도를 금지하는 데이터베이스의 규칙은 더 이상 극복해야 할 한계가 아니라, 쓰기 작업이 애초에 멱등성을 가지므로 이미 만족시킨 제약 조건이 됩니다. 데이터베이스의 엄격함과 여러분의 ID 체계는 결국 같은 방향을 가리키게 됩니다.

관련 글

데이터베이스

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