Hangfire에서 RabbitMQ로: .NET 앱에서 데이터베이스 폴링(Database Polling) 제거하기
요약
Hangfire의 데이터베이스 폴링 방식이 가진 확장성 문제를 해결하기 위해 RabbitMQ로 마이그레이션한 사례를 다룹니다. 작업량과 관계없이 지속적으로 발생하는 DB 부하를 줄이고 시스템 효율을 높이는 과정을 설명합니다.
핵심 포인트
- Hangfire는 소규모 서비스에서 설정이 간편하고 대시보드 제공 등 장점이 많음
- 작업 종류와 상관없이 지속적인 DB 폴링으로 인한 부하 발생 가능성 존재
- 확장성을 위해 데이터베이스 기반 폴링에서 메시지 브로커(RabbitMQ)로 전환
- 서비스 규모와 요구되는 확장성에 따른 적절한 백그라운드 작업 도구 선택 필요
Hangfire는 거의 노력 없이도 당신을 똑똑해 보이게 만듭니다. NuGet 패키지를 추가하고 이미 사용 중인 데이터베이스를 지정하기만 하면, 작업 실행기(job runner)와 대시보드(dashboard)를 무료로 얻을 수 있습니다. 단일 .NET 서비스라면 정말 이보다 더 나은 대안을 찾기 어렵습니다.
그런데 왜 저는 앱 전체에서 이를 제거했을까요?
제가 실행하던 것이 단 하나의 작업이 아니었기 때문입니다. 저는 Hangfire를 통해 모든 작업을 실행하고 있었고, 그 작업 하나하나가 몇 초마다 Postgres에 접속하여 _"나를 위한 것이 아직 없나요?"_라고 묻고 있었습니다.
이 글은 제가 만들고 있는 나중에 읽기(read-it-later) 앱인 SavePosty의 모든 폴링(polling) Hangfire 작업을 RabbitMQ로 이전한 이야기입니다. 얻은 이점, 지루할 정도로 안정적이었던 마이그레이션 패턴, 그리고 잃게 되는 한 가지에 대한 솔직한 이야기입니다.
이전에 실행하던 방식
앱 전반에 걸쳐 Hangfire는 PostgreSQL을 기반으로 모든 백그라운드 작업(이메일 전송, 웹훅(webhook) 발송, 콘텐츠 재가져오기 등)을 실행했습니다. 작업의 종류는 달랐지만 메커니즘은 동일했습니다. 각 작업은 타이머를 통해 데이터베이스를 폴링(polling)하여 수행할 작업을 찾아냈습니다.
공정하게 말하자면, 이전 도구가 쓰레기였다고 주장하는 마이그레이션 포스트들은 거짓말을 하고 있는 것입니다. Hangfire는 저에게 다음과 같은 것들을 제공했습니다:
- 새로운 인프라가 필요 없음 — 이미 실행 중인 Postgres 위에서 작동합니다.
- 즉시 사용 가능한 대시보드: 대기 중(queued), 처리 중(processing), 성공(succeeded), 실패(failed) 상태 확인 가능.
- 단일 어트리뷰트(attribute)로 구현되는 재시도 로직(Retry logic).
만약 소규모 팀이 단일 서비스를 운영하고 있다면, 솔직히 그냥 Hangfire를 사용하세요. 만족하실 겁니다.
마이그레이션을 한 이유
Hangfire가 나빠서 떠난 것이 아닙니다. 제 상황이 변했기 때문에 떠난 것입니다.
1. 작업량이 아닌 시간에 따라 확장되는 폴링 (Polling) 부하. 모든 Hangfire 작업은 할 일이 있든 없든 일정 간격으로 Postgres에 접근합니다. 작업이 하나라면 눈에 띄지 않습니다. 하지만 저는 동일한 데이터베이스를 계속해서 폴링하는 수많은 작업을 가지고 있었습니다. 이는 실제 처리량 (Throughput)이 아니라, 폴링 빈도와 작업 수에 따라 증가하는 지속적인 읽기 부하 (Read load)를 발생시켰습니다. 앱이 유휴 상태 (Idle)일 때도 이 비용을 지불해야 했습니다.
2. 한 시스템 내의 두 가지 작업 메커니즘. PostyFetch라는 하나의 서비스는 이미 RabbitMQ에서 실행되고 있었습니다. 하지만 그 외의 모든 것은 여전히 Hangfire를 폴링하고 있었습니다. 결과적으로 저는 "백그라운드에서 작업 수행"을 위해 완전히 다른 두 가지 모델을 유지해야 했습니다. 즉, 두 개의 사고 모델 (Mental models), 두 개의 운영 매뉴얼 (Runbooks), 그리고 무언가 고장 났을 때 확인해야 할 두 곳의 장소를 관리해야 했습니다. 모든 것을 브로커 (Broker)로 옮기면서 이 구조를 하나로 통합할 수 있었습니다.
또한 브로커는 다음 폴링을 기다리는 대신, 작업이 도착하는 즉시 작업을 "푸시 (Push)"합니다. 대부분의 작업에서 이러한 지연 시간 (Latency) 이득은 작지만, 앞서 언급한 두 가지 이유와 결합했을 때 나아가야 할 방향은 명확했습니다.
솔직한 트레이드오프 (Tradeoff)
어떠한 마이그레이션도 공짜는 아닙니다. 만약 이 표가 Hangfire를 너무 쉬워 보이게 만든다면, 그것은 단일 서비스의 경우에는 실제로 그렇기 때문입니다.
| Hangfire | RabbitMQ | |
|---|---|---|
| 설정 (Setup) | 즉시 사용 가능한 NuGet | 배포 및 관리가 필요한 브로커 |
| ... |
RabbitMQ를 선택하는 이유는 "더 낫기 때문"이 아닙니다. "폴링을 수행해야 할 추가 서비스와 추가 작업이 늘어날수록 점점 더 좋아지기 때문"입니다.
지루함을 유지해 준 패턴: 얇은 컨슈머 (Thin consumers)
이 부분이 바로 다수의 작업을 옮기는 마이그레이션을 공포가 아닌 안전한 작업으로 만들어 준 핵심입니다.
각 RabbitMQ 소비자(Consumer)는 가능한 한 최소한의 작업만 수행합니다: 메시지를 수신하고, 역직렬화(Deserialize)한 뒤, **기존 작업 클래스(Job class)에 위임(Delegate)**합니다. 비즈니스 로직은 이동하지 않습니다. 이메일을 보내거나 웹훅(Webhook)을 발송하는 코드는 원래 있던 위치에 그대로 유지되며, 여전히 독립적으로 테스트 가능합니다. 소비자는 변경되지 않은 로직 앞에 놓인 새로운 앞문일 뿐이며, 저는 마이그레이션한 모든 작업에 대해 동일한 형태를 사용했습니다.
// 소비자는 얇은 껍데기(Thin shell)입니다. 동작(Behavior)이 아닌 전송(Transport)을 담당합니다.
public class SendEmailConsumer : IConsumer<SendEmailMessage>
{
...
가장 먼저 처리함으로써 제대로 해낸 두 가지가 있습니다:
- 재시도 일치성 (Retry parity). 틀리기 쉬운 부분이었기에 다른 무엇보다 먼저 수행했습니다. Hangfire의
[AutomaticRetry(Attempts = 0)]은 소비자에서의MaxRetries = 0이 되었습니다. 이를 잘못 설정하면 실패하는 다운스트림(Downstream)을 영원히 계속 두드리거나, 재시도해야 할 메시지를 조용히 누락시키게 됩니다. - 스키마 변경 시 중단 방지 (No breaking schema change). 기존의
HangfireJobId컬럼을 Null 허용(Nullable) 상태로 두었습니다. 기존 행들은 자신의 ID를 유지하고, 새로운 행들은 이를 null로 남겨두므로, 데이터 계층(Data layer)에서 조정된 배포(Coordinated deploy)가 필요하지 않았습니다.
신뢰성: 모든 큐를 위한 데드 레터 큐 (Dead-letter queue)
모든 작업은 각자의 큐와 각자의 DLQ(Dead-letter queue)를 가집니다 — postysend.sendemail.dlq, postysend.webhookdispatch.dlq 등과 같은 식입니다. 메시지가 재시도 횟수를 모두 소진하면, 증발하는 대신 해당 DLQ에 안착합니다.
여기에 숨겨진 조용한 업그레이드도 있습니다. 작업이 이제 소비자로서 실행되기 때문에, 작업 스스로 메시지를 **발행(Publish)**할 수 있습니다. 저는 하나의 작업을 IBackgroundJobClient에서 IMessagePublisher로 옮겼고, 이제 이메일을 보내는 작업은 WebhookDispatch 메시지를 직접 발행합니다. 이러한 서비스 간 체이닝(Service-to-service chaining)은 Hangfire에서는 어색하지만, 브로커(Broker)에서는 매우 자연스럽습니다.
실제로 잃게 되는 것
여기가 대부분의 "우리는 마이그레이션했고 정말 놀랍다"라고 말하는 게시물들이 생략하는 부분입니다.
메시지별 실패 작업 검사 (per-message failed-job inspection) 기능을 잃게 됩니다. Hangfire의 대시보드는 단일 실패 작업을 열어서 정확히 어떤 일이 일어났는지 읽을 수 있게 해주었습니다. 반면 RabbitMQ의 관리 뷰(management view)는 DLQ 카운트 (DLQ counts), 즉 얼마나 많은 메시지가 데드 레터(dead-lettered) 되었는지만 알려줄 뿐, 큐를 직접 확인하지 않고는 각 메시지에 대한 전체 스토리를 보여주지 않습니다.
이것은 명백한 성능 저하(downgrade)입니다. 만약 당신의 디버깅 워크플로우가 한 번에 하나의 잘못된 작업을 열어보는 방식에 의존하고 있다면, 전환을 완료한 후가 아니라 전환하기 전에 이 공백에 대한 계획을 세우십시오. 저는 이제 예쁜 작업별 화면 대신 구조화된 로그(structured logs)와 DLQ 재생(replay)을 사용하고 있으며, 이는 적응이 필요한 과정이었습니다.
이 작업을 수행해야 할까요?
스스로에게 솔직해지십시오.
다음의 경우 Hangfire를 유지하십시오: 소규모 팀과 함께 단일 서비스를 운영하고, 브로커(broker)가 없으며, 대시보드와 작업별 검사가 필요하고, 현재 규모에서 DB 폴링(DB polling) 부하가 문제가 되지 않는 경우입니다. 이 중 어느 것도 부끄러운 이유가 아닙니다. 이는 훌륭한 엔지니어링입니다.
다음의 경우 RabbitMQ로 이동하십시오: 여러 서비스를 운영하거나, 데이터베이스를 폴링하는 것 자체가 측정 가능한 비용이 될 정도로 작업이 많은 경우; 이미 브로커를 운영 중이거나 운영할 수 있는 경우; 또는 Hangfire가 근본적으로 수행할 수 없는 발행/구독(pub/sub) 및 팬아웃(fan-out) 기능이 필요한 경우입니다.
결정은 단 하나의 작업에 관한 것이 아니었습니다. 그것은 _시스템(system)_에 관한 것이었습니다. 많은 움직이는 부품들을 조정해야 할 때는 RabbitMQ가 승리하며, 그렇지 않을 때는 Hangfire가 승리합니다.
저는 여러분이 저장하는 모든 것을 위한 더 빠르고 스마트한 공간인 SavePosty를 공개적으로 구축하고 있습니다. 내부 구조에 관심이 있다면, 구축 과정을 팔로우해 주세요. 마이그레이션에 대한 질문이 있으신가요? 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기