MongoDB 변경 스트림을 사용한 Cron 스케줄 핫스와핑
cron을 하드코딩하면 스케줄이 변경될 때마다 재배포를 해야 합니다. 스케줄을 데이터베이스에 저장하고 데몬이 변경 스트림(change streams)으로 편집에 반응하도록 하십시오. 여기에 그 설계와 실패 처리가 있습니다.
백그라운드 작업이 코드나 환경 변수에 내장된 cron 표현식으로 실행된다면, 작업 실행 시간을 변경하는 것은 재배포를 의미합니다. 보고서 시간을 오전 9시에서 오전 8시로 옮기면, 숫자 하나를 바꾸기 위해 빌드를 내보내고, 파이프라인을 실행하며, 프로세스를 재시작해야 합니다. 이는 느리고, 변경에 비해 위험이 크며, 시스템을 건강하게 유지하는 사소한 조정을 방해합니다. 해결책은 스케줄을 데이터베이스에 저장하고 스케줄러가 실시간으로 편집에 반응하도록 하는 것입니다. MongoDB 변경 스트림은 이 반응을 즉각적으로 만듭니다. 이 게시물에서는 설계, 안정성을 높이는 재연결 처리, 그리고 필요 이상의 장치가 되는 경우를 다룹니다.
코드 내 스케줄의 문제점
하드코딩된 스케줄은 완전히 다른 속도와 완전히 다른 이유로 변경되는 두 가지를 결합합니다. 작업의 로직은 작업이 변경될 때 변경되며, 이는 빌드에 속하는 엔지니어링 결정입니다. 작업의 타이밍은 보고서가 회의 전에 도착해야 하거나 무거운 작업을 피크 시간대 밖으로 옮겨야 하는 등 운영상의 필요가 변경될 때 바뀌며, 이는 코드와는 아무런 관련이 없는 운영상의 결정입니다. 이 둘을 묶는 것은 모든 타이밍 조정이 코드 배포라는 무거운 부담을 수반한다는 것을 의미합니다.
비용은 단지 배포 시간만이 아닙니다. 그것은 위축 효과입니다. 스케줄 변경이 프로덕션 배포가 되면 사람들은 그 일을 그만둡니다. 아무도 작업을 한 시간 옮기기 위해 빌드를 배포하고 싶어하지 않기 때문에 스케줄은 실제 필요와 어긋나게 됩니다. 스케줄은 릴리스해야 하는 코드가 아니라 편집할 수 있는 데이터여야 합니다.
데이터로서의 스케줄
첫 번째 단계는 스케줄을 컬렉션에 넣는 것입니다. 각 작업당 하나의 문서를 사용하고 cron 표현식을 필드로 포함합니다.
// One document per scheduled job. The cron expression is data, not code.
type Schedule struct {
ID string `bson:"_id"` // e.g. "daily_report"
CronExpr string `bson:"cron_expr"` // e.g. "0 9 * * *"
UpdatedAt time.Time `bson:"updated_at"`
}
이제 스케줄 변경은 배포 없이 단일 데이터베이스 업데이트로 이루어집니다.
db.schedules.updateOne(
{ _id: "daily_report" },
{ $set: { cron_expr: "0 8 * * *", updated_at: new Date() } }
)
하지만 데이터만으로는 충분하지 않습니다. 무언가가 변경 사항을 감지하고 실행 중인 스케줄러를 재구성해야 합니다. 이를 위한 두 가지 방법이 있으며, 그 둘의 차이점이 이 게시물의 핵심입니다.
폴링 대 반응
간단한 접근 방식은 폴링하는 것입니다. 매분 스케줄러는 컬렉션을 다시 읽고 변경된 사항이 있으면 타이머를 다시 빌드합니다. 이 방식은 작동하며 많은 시스템에서 전적으로 적합합니다. 약점은 지연 시간과 낭비입니다. 변경 사항이 적용되려면 전체 폴링 간격만큼의 시간이 걸릴 수 있으며, 대부분의 폴링에서는 변경된 사항이 없어 순수한 오버헤드만 발생합니다. 지연 시간을 줄이기 위해 간격을 줄이면 낭비가 늘어납니다. 이것은 좋은 설정은 없고 덜 나쁜 설정만 있는 다이얼과 같습니다.
변경 스트림은 이 다이얼을 제거합니다. 타이머에서 "변경된 사항이 있습니까?"라고 묻는 대신, 스케줄러는 라이브 피드를 구독하고 무언가 변경되는 즉시 알림을 받습니다. 스케줄 편집은 거의 실시간으로 전파되며, 아무것도 변경되지 않으면 스케줄러는 아무것도 하지 않고 비용도 발생하지 않습니다. 동시에 더 낮은 지연 시간과 더 적은 작업을 얻게 되는데, 이는 이 메커니즘에 필요한 추가적인 주의를 기울일 가치가 있을 만큼 이례적인 일입니다.
변경 스트림이란
변경 스트림은 컬렉션에서 발생하는 쓰기 작업에 대한 실시간 순차 피드입니다. 내부적으로는 데이터베이스가 복제본 세트 멤버를 동기화 상태로 유지하는 데 사용하는 것과 동일한 복제 로그를 읽으므로, 폴링 없이 inserts, updates, deletes가 커밋되는 즉시 순서대로 볼 수 있습니다. 데몬은 스트림을 한 번 열고 블로킹 상태가 되며, 스케줄 문서가 변경될 때마다 이벤트를 수신합니다.
// Watch the schedules collection. Block until an edit arrives, then apply it.
stream, err := coll.Watch(ctx, mongo.Pipeline{})
if err != nil {
return fmt.Errorf("open change stream: %w", err)
}
defer stream.Close(ctx)
for stream.Next(ctx) {
var event struct {
FullDocument Schedule `bson:"fullDocument"`
}
if err := stream.Decode(&event); err != nil {
logger.Warn("decode change event", logger.Err(err))
continue
}
scheduler.Reload(event.FullDocument) // rebuild this job's timer
}
운영자가 한 줄짜리 업데이트를 실행하면, 데몬은 즉시 이벤트를 수신하고 영향을 받은 타이머만 다시 빌드합니다. 재배포, 재시작, 폴링 간격 대기가 필요 없습니다. 이제 스케줄은 진정한 실시간 데이터가 됩니다.
변경 스트림은 복제 로그를 읽기 때문에 복제본 세트가 필요하며, 독립 실행형 서버는 이 로그를 유지하지 않습니다. 모든 프로덕션 MongoDB 배포는 이미 복제본 세트이므로 이것이 새로운 요구사항이 되는 경우는 드물지만, 이 기능을 사용할 수 없는 로컬 단일 노드 설정에서 사용하기 전에 알아두는 것이 좋습니다.
신뢰성을 보장하는 부분: 재개 토큰
단순한 변경 스트림에는 공백이 있습니다. 네트워크 장애, 장애 조치, 잠시 재시작 등으로 연결이 끊겼을 때 단순히 스트림을 다시 열면, 현재 시점부터 재개되어 연결이 끊겨 있던 동안 발생한 모든 변경 사항을 조용히 놓치게 됩니다. 30초간의 재연결 동안 수정된 스케줄은 손실될 것이고, 데이터베이스는 다른 내용을 담고 있는데도 데몬은 계속해서 이전 타이밍으로 실행될 것입니다. 이러한 불일치는 바로 이 전체 설계가 막으려고 했던 버그입니다.
변경 스트림은 재개 토큰으로 이 문제를 해결합니다. 모든 이벤트는 피드에서의 위치를 표시하는 토큰을 가지고 있습니다. 처리한 최신 토큰을 영속화하고, 연결이 끊긴 후에는 현재 시점 대신 해당 토큰부터 스트림을 다시 엽니다. 데이터베이스는 놓친 모든 변경 사항을 순서대로 다시 재생하고, 정확히 중단했던 지점부터 따라잡게 됩니다.
// Resume from the last processed position so a reconnect misses nothing.
opts := options.ChangeStream()
if resumeToken != nil {
opts.SetResumeAfter(resumeToken)
}
stream, err := coll.Watch(ctx, mongo.Pipeline{}, opts)
// ... after handling each event, persist stream.ResumeToken() durably
각 이벤트를 처리한 후 데몬이 재시작되어도 유지될 만큼 영속적으로 토큰을 저장하는 것이 원칙입니다. 재연결 시에는 이를 로드하여 거기서부터 재개합니다. 이것이 준비되면 스트림은 빠를 뿐만 아니라, 장기 실행되는 모든 컨슈머에서 불가피한 연결 끊김에도 공백이 없어집니다. 토큰은 편리한 기능을 신뢰할 수 있는 기능으로 바꿔주는 요소입니다.
한 가지 주의할 점은, 재개 토큰은 유한한 크기를 가진 복제 로그에 변경 사항이 아직 존재할 동안에만 유효하다는 것입니다. 데몬이 오랫동안 다운되어 가장 오래된 미처리 변경 사항이 이미 로그에서 밀려났다면, 토큰은 무효화되고 스트림은 그 지점부터 재개할 수 없습니다. 이러한 경우는 무효한 재개 오류를 감지하고 컬렉션의 전체 리로드로 폴백하여 처리하며, 이는 스케줄러를 현재의 진실된 상태로 다시 동기화합니다. 이는 어차피 최초 시작 시에도 원할, 처음부터 다시 조정하는 경로와 동일합니다.
중단 없이 변경 사항 적용하기
이벤트를 수신하는 것은 작업의 절반에 불과합니다. 실행 중인 스케줄러를 변경하는 것이므로 적용 시에는 신중해야 합니다. 한 스케줄의 편집이 다른 스케줄을 방해하거나 교체 중 이중 실행의 위험을 초래하지 않도록, 모든 타이머를 해체하고 다시 생성하는 대신 변경된 작업에 대한 타이머만 특정하여 다시 빌드하십시오. 해당 job id의 이전 타이머를 취소하고, 업데이트된 cron 표현식으로 새 타이머를 설치하며, 다른 모든 작업은 그대로 두십시오. resume-from-token이 재연결 경계(reconnect boundary)에서 유발할 수 있는 것처럼 동일한 이벤트를 두 번 적용하더라도, 스케줄러가 한 번 적용했을 때와 동일한 상태가 되도록 재구성을 멱등성(idempotent) 있게 유지하십시오. 바로 그 멱등성이 재개된 스트림의 최소 한 번(at-least-once) 특성을 안전하게 만듭니다.
단순하게 유지해야 할 때
변경 스트림은 스케줄 변경이 너무 잦아서 재배포가 정말 부담스러울 때나 변경 사항이 적용되는 지연 시간이 실제로 중요할 때 적합한 도구입니다. 스케줄이 거의 변경되지 않는다면 운영상의 이점은 작으며, 단순한 재시작을 통한 리로드나 느린 폴링 방식이 구축할 것도 적고 고장 날 것도 적습니다. 누군가 일 년에 한두 번 스케줄을 편집하는 시스템에 라이브 이벤트 소비자, 재개 토큰 영속성, 그리고 유효하지 않은 토큰 폴백을 추가하지 마십시오.
더 깊은 원칙은 cron을 넘어 일반화됩니다. 코드상의 이유가 아닌 운영상의 이유로 변경되는 모든 설정은 실행 중인 시스템이 감시하는 데이터 저장소에 있어야 하며, 그래야 변경 작업이 릴리스가 아닌 편집이 됩니다. 스케줄은, 이전에는 배포였던 타이밍 변경이 한 줄짜리 업데이트가 된다는 점에서 이점이 매우 구체적이어서 명확한 예시이지만, 동일한 패턴이 피처 플래그, 라우팅 규칙, 그리고 사용량 제한에도 적합합니다. 설정을 데이터로 저장하고, 변경 스트림으로 감시하며, 재개 토큰으로 재연결을 처리하십시오. 이 세 가지 조합이 라이브 설정을 단순히 편리한 것을 넘어 신뢰할 수 있게 만드는 요소입니다.
관련 글
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인 경우, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.
Redis 키스페이스 알림과 임대를 이용한 GPU 워커 풀 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨 인지 스케줄러가 있습니다.
단일 실행기 작업을 위한 CAS 및 하트비트를 사용한 임대 잠금
홀더가 죽으면 일반적인 락은 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기에 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 만드는 방법과 설계를 결정하는 실패 모드가 있습니다.
멱등성 업서트를 위한 결정적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등적이어야 합니다. 콘텐츠 기반 ID는 이를 간단하게 만듭니다. 여기에 그 유도 과정과 안전성을 유지하는 한 가지 불변식이 있습니다.
백엔드를 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로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.