데이터베이스

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인 경우, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.

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

Enterprise editionFirestoreMongoDB 와이어 프로토콜을 사용하므로 표준 MongoDB 드라이버를 연결하면 대부분의 기능이 그대로 작동합니다. MongoDB 쿼리 구문, MongoDB 인덱스, 그리고 실행할 서버가 없는 관리형 데이터베이스를 얻게 됩니다. 그러다 때때로 데이터와는 아무 관련이 없는 오류가 발생하며 일부 쓰기 작업이 실패하기 시작합니다. 그 원인은 Firestore가 지원하지 않는 드라이버 기본값인 retryWrites입니다. 이 게시물에서는 해당 설정의 기능, 호환성 레이어가 이를 거부하는 이유, 실패를 인식하는 방법, 그리고 이 설정을 끈 후에 시스템에 대해 어떤 점을 고려해야 하는지를 설명합니다.

재시도 가능 쓰기(retryable writes)의 실제 동작 방식

실제 MongoDB에서 재시도 가능 쓰기는 첫 번째 시도가 네트워크 또는 선출(election) 계층에서 실패할 경우, 드라이버가 쓰기 작업을 정확히 한 번 다시 보낼 수 있게 해줍니다. 이때 쓰기 작업이 두 번 적용될 위험이 없습니다. 이 기능은 작업에 트랜잭션 번호를 첨부하여 작동합니다. 서버는 그 번호를 기록하므로, 동일한 작업을 다시 받게 되면 이것이 이미 적용했을 수 있는 쓰기의 재시도임을 인지하고, 두 번째 복사본을 쓰는 대신 중복을 제거합니다.

이것은 정말 좋은 기본값입니다. 네트워크는 신뢰할 수 없으며, 프라이머리 선출(primary election)은 발생합니다. 재시도 가능 쓰기는 전체 유형의 일시적인 실패를 조용하고 안전한 복구로 전환합니다. 이것이 모든 최신 MongoDB 드라이버가 retryWrites=true와 함께 제공되는 이유이며, 대부분의 사람들이 이 기능에 대해 전혀 생각하지 않는 이유입니다. 이 기능은 보이지 않는 곳에서 제 역할을 하고 있습니다.

중요한 세부 사항은 안전성이 전적으로 해당 트랜잭션 번호 기록을 구현하는 서버에 달려 있다는 것입니다. 클라이언트는 자체적으로 쓰기 작업을 멱등성(idempotent) 있게 만들 수 없습니다. 클라이언트는 서버에 중복 제거를 요청하고, 서버가 그 방법을 알고 있다고 신뢰할 수밖에 없습니다.

Firestore가 이를 거부하는 이유

Firestore의 MongoDB 호환성은 와이어 프로토콜과 쿼리 표면을 구현하지만, 그 이면에는 다른 트랜잭션 모델을 가진 다른 스토리지 엔진이 있습니다. 이는 프로토콜의 트랜잭션 번호가 가정하는 retryable-write 관리를 구현하지 않습니다. 그래서 드라이버가 친절하게도 사용자의 insert에 retryable-write 메타데이터를 첨부하면, 서버는 지원하지 않는 기능을 발견하고 작업을 거부합니다.

이 실패 모드는 오후 내내 시간을 허비하게 만드는 부분입니다. 오류 메시지는 "retryWrites가 지원되지 않으니 비활성화하세요."라고 말하지 않습니다. 더 일반적인 쓰기 실패로 나타나며, 어떤 코드 경로가 언제 메타데이터를 첨부했는지에 따라 달라지기 때문에 간헐적으로 보일 수 있습니다. 일부 쓰기에서는 발생하고 다른 쓰기에서는 발생하지 않거나, 시작 시에는 발생하지만 다른 연결을 사용하는 단위 테스트에서는 발생하지 않는 것을 보게 될 것입니다. 본능적으로 문서, 스키마, 인덱스를 검사하게 됩니다. 문서는 괜찮습니다. 연결이 잘못 구성된 것입니다.

해결책은 플래그 하나이며, 어디에 두느냐가 중요합니다

연결 URI에 retryWrites=false를 설정하세요. 이것이 해결책의 전부이며, 맨 처음 여는 연결에 반드시 존재해야 합니다.

// retryWrites=false is required. Firestore's MongoDB compatibility rejects
// the retryable-write protocol that the driver enables by default.
uri := "mongodb://USER:PASS@HOST/db?retryWrites=false"

client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
	return nil, fmt.Errorf("connect firestore: %w", err)
}

코드 옵션(options.Client().SetRetryWrites(false))으로 비활성화할 수도 있지만, URI를 사용하는 것이 좋습니다. 그 이유는 기술적인 것이 아니라 사람 때문입니다. URI 문자열은 사람들이 새로운 서비스, 마이그레이션 스크립트, 일회성 디버깅 셸, 팀원의 환경 파일 등으로 복사하는 대상입니다. 복사된 모든 곳은, 만약 설정이 함께 따라오지 않는 코드 안에 있었다면 true 기본값으로 조용히 되돌아갈 수 있는 지점입니다. 연결 문자열에 넣으면 데이터베이스를 사용할 때마다 함께 따라다닙니다.

시작 시에 검증하여 잊어버리는 것을 불가능하게 만드세요. 서비스에 전달된 URI가 재시도 가능 쓰기를 비활성화하지 않는 경우, 첫 쓰기에서 의문스럽게 실패하는 대신 부팅 시에 큰 소리로 실패하게 하세요.

if !strings.Contains(uri, "retryWrites=false") {
	return nil, errors.New("firestore URI must set retryWrites=false")
}

비활성화해도 쓰기 작업이 안전해지지 않습니다

이 부분이 사람들이 오류를 수정하고 넘어간 후에 발목을 잡는 부분입니다. retryWrites를 비활성화해도 재시도할 필요성이 사라지지 않았습니다. 네트워크는 여전히 끊기고, 타임아웃은 여전히 발생하며, 여러분의 코드는 어딘가에서 성공했을 수도 있고 아닐 수도 있는 쓰기 작업을 여전히 재전송할 것입니다. 바뀐 점은 더 이상 아무것도 그 재전송을 중복 제거하지 않는다는 것입니다. 실제 MongoDB에 있던 안전망은 사라졌고, 그 책임은 여러분에게 넘어갔습니다.

시스템의 어떤 쓰기 경로든 재시도될 수 있다면, 그리고 분산 시스템에서는 모든 쓰기 경로가 그럴 수 있는데, 그렇다면 해당 쓰기 작업은 구조적으로 멱등성을 가져야 합니다. 동일한 쓰기 작업을 두 번 실행해도 데이터베이스는 한 번 실행했을 때와 동일한 상태로 남아야 합니다.

이를 깔끔하게 해결하는 방법은 재전송할 수 있는 모든 것에 대해 무작위 문서 ID 사용을 중단하는 것입니다. 문서를 식별하는 콘텐츠에서 ID를 파생시켜, 재시도된 삽입이 동일한 ID를 대상으로 하고 중복된 행 대신 무해한 덮어쓰기로 전환되도록 하세요.

// Same logical document, same id, every time. A retried upsert overwrites
// in place instead of inserting a second copy.
id := PostID(slug, lang, publishedAt)

_, err := coll.UpdateByID(ctx, id,
	bson.M{"$set": doc},
	options.Update().SetUpsert(true),
)

upsert와 결정론적 ID를 사용하는 UpdateByID는 서버 측 중복 제거 없이도 그 자체로 멱등성을 가집니다. 첫 번째 호출은 삽입하고, 모든 재시도는 동일한 데이터를 덮어쓰며, 행 수는 절대 늘어나지 않습니다. 나중에 절대 변경해서는 안 되는 필드(그렇지 않으면 이전 문서가 고아가 됨)를 포함한 전체 파생 과정은 멱등성 upsert를 위한 결정론적 ULID에서 다룹니다.

알아두면 좋은 다른 호환성 관련 주의사항

retryWrites는 일반적인 쓰기 작업에서 실패하기 때문에 가장 먼저 문제를 일으키는 부분이지만, 동일한 원칙이 호환성 계층 전반에 적용됩니다. 즉, 와이어 프로토콜은 지원되지만 그 뒤에 있는 모든 서버 측 기능이 지원되는 것은 아닙니다. 어떤 기능에 의존하기 전에, 호환되는 엔진이 단순히 명령을 수락하는 것이 아니라 실제로 그 기능을 구현했는지 확인해야 합니다. 실제로는 호환성 계층을 드롭인(drop-in) MongoDB로 취급하는 것이 아니라, 자체적인 한계를 가진 별도의 데이터베이스로 다루어야 한다는 의미입니다. 운영 환경에서 사고를 통해 각 격차를 발견하는 대신, 지원되는 기능 문서를 미리 한 번 읽어보십시오.

사고방식의 전환이 진정한 교훈입니다. 호환성 계층은 프로토콜과 쿼리 언어를 제공하며, 이는 사용 편의성의 대부분을 차지합니다. 호환성 계층은 원본의 모든 운영상의 보장을 약속하지 않으며, 생략되는 보장들은 자동 쓰기 중복 제거처럼 사용자가 인지하지 못한 채 의존하고 있던 바로 그 보이지 않는 기능들인 경향이 있습니다.

연결을 신뢰하기 전 체크리스트

  • retryWrites=false는 한 클라이언트의 코드 옵션에만 있는 것이 아니라 URI 자체에 있으므로, 연결 문자열의 모든 복사본과 함께 전달됩니다.
  • 플래그가 없으면 시작 유효성 검사가 큰 소리로 실패하므로, 잘못 구성된 환경은 첫 번째 쓰기에 도달할 수 없습니다.
  • 모든 쓰기 경로는 임의의 ID를 사용하는 삽입이 아니라, 안정적이고 콘텐츠에서 파생된 ID를 키로 사용하는 멱등성 있는 업서트(upsert)입니다.
  • 재시도 로직은 최소 한 번 전송(at-least-once delivery)을 가정하므로, 어떤 쓰기를 다시 실행해도 동일한 상태가 유지됩니다.

처음 두 가지를 수행하면 호환성 계층에서 임의적으로 보이는 오류를 던지는 것을 멈춥니다. 마지막 두 가지를 수행하면 서버의 안전망이 사라졌을 때 자체 재시도가 조용히 데이터를 중복시키는 것을 멈춥니다. 첫 번째 쌍은 증상을 사라지게 합니다. 두 번째 쌍은 그 증상이 경고하던 문제를 해결합니다.

관련 글

백엔드

백엔드를 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