Redis Pub/Sub과 Stream, 왜 둘 다 썼는가

현장 설비(PLC·센서·계측기)에서 초 단위로 유입되는 IoT 포인트 값을, 여러 테넌트의 화면·알람·리포트에 동시에 전달해야 했습니다. 약 4,800개 포인트가 대상이었고, 처음 이 구조를 설계할 때 가장 먼저 부딪힌 질문은 “값을 어디까지 DB에 즉시 기록해야 하는가”였습니다.

하나만 쓰면 안 되는 이유

값을 매번 DB에 즉시 기록하면 쓰기 부하가 병목이 됩니다. 반대로 메모리(Pub/Sub)에만 흘려보내면 구독자가 잠깐 끊긴 순간의 값은 그대로 사라집니다. 그리고 실제 소비자들은 이 두 실패 조건 중 어느 쪽도 감당할 수 없었습니다.

  • 화면(대시보드): 지금 이 순간의 최신값만 필요합니다. 0.3초 전 값이 잠깐 안 보였다고 문제가 되지 않습니다. 대신 지연 없이 빨리 와야 합니다.
  • 알람 평가·이력 적재: 값 하나라도 유실되면 안 됩니다. “그 순간 온도가 임계치를 넘었었다”는 사실 자체가 데이터이기 때문에, 구독자가 잠깐 끊겼다고 사라지면 안 됩니다.

이 두 소비자를 하나의 채널로 강제하면 반드시 한쪽이 손해를 봅니다. Stream만 쓰면 화면 하나 띄우는 데도 컨슈머 그룹 관리와 ack 오버헤드가 붙고, Pub/Sub만 쓰면 알람·이력이 구조적으로 불안정해집니다. 그래서 point_subscriptions 테이블에 deliveryReliability(realtime / durable / both) 값을 두고, 구독 설정에 따라 배분 채널이 결정되도록 했습니다. 화면은 realtime, 알람·이력은 durable, 둘 다 필요한 경우는 both를 선택합니다.

순서 보장과 중복 수신은 어떻게 판단했나

여러 인스턴스가 동시에 값을 발행·소비하는 구조이다 보니 “메시지가 도착 순서대로 처리되는가”라는 질문이 따라옵니다. 여기서 내린 결론은, 서버 수신 순서를 신뢰하지 않는다는 것이었습니다.

현재값을 보여주는 미러 테이블은 서버가 메시지를 받은 순서가 아니라, 디바이스가 값을 측정한 시각(포인트 자체의 타임스탬프)을 기준으로 최신값을 판단합니다. 네트워크 지연이나 재시도로 메시지 도착 순서가 뒤바뀌어도, 미러 테이블에는 “측정 시각이 더 최신인 값”이 남습니다. 화면에는 어차피 최신값만 필요하므로 최신값 덮어쓰기로 충분하다는 게 의도적인 결론이었고, 굳이 전역 순서를 보장하기 위한 락이나 시퀀스 번호 같은 무거운 장치를 두지 않았습니다.

반면 원본 이력(중앙 테이블)은 덮어쓰지 않고 모두 적재하기 때문에, 순서가 뒤바뀌어 들어와도 나중에 디바이스 타임스탬프로 정렬하면 정확한 순서를 복원할 수 있습니다. “실시간 표시용 최신값”과 “감사·분석용 원본 이력”의 정확성 요구 수준이 다르다는 점을 인정하고, 두 구조에 서로 다른 기준을 적용한 것이 핵심입니다.

메시지당 DB 조회 0회로 만들기

Stream으로 들어온 메시지를 처리할 때마다 “이 포인트가 어떤 구독자에게 배분되어야 하는가”를 DB에 물어보면, 초당 수천 건의 조회가 그대로 병목이 됩니다. 그래서 구독 메타데이터를 30초 주기로 갱신되는 인메모리 캐시에 올려두고, 메시지 처리 시점에는 캐시만 조회합니다. 알람 규칙이 없는 포인트는 이 단계에서 아예 DB 접근 자체를 차단합니다.

캐시가 30초 지연을 갖는다는 것은, 구독 설정을 바꾼 직후 최대 30초 동안은 이전 설정이 적용된다는 뜻입니다. 이 지연을 감내할 수 있다고 판단한 이유는 구독 설정 변경 빈도가 실시간 데이터 유입 빈도에 비해 압도적으로 낮기 때문입니다. 반대로 캐시 갱신 자체가 실패하는 경우(Redis 일시 장애 등)에는 예외를 로깅만 하고 기존 캐시를 그대로 유지하도록 했습니다. 캐시 갱신 실패가 파이프라인 전체를 멈추는 것보다, 약간 오래된 설정으로라도 계속 돌아가는 쪽이 훨씬 안전하다고 판단했습니다.

배치 주기를 1.5초로 정한 이유

같은 포인트에 대해 짧은 시간 안에 여러 값이 들어오면, 미러 테이블에는 최종값 하나만 있으면 됩니다. 그래서 100ms 주기로 중복 메시지를 걸러내며 재발행하고, 1.5초 주기로 모아서 배치 UPSERT를 실행합니다.

500ms처럼 더 짧은 주기는 트랜잭션 수가 늘어나는 만큼 DB 락·WAL 오버헤드가 커지는 데 비해 화면 체감 개선은 미미했습니다. 반대로 5초처럼 긴 주기는 운영자가 보기에 “화면이 느리다”고 느껴질 만한 지연이었습니다. 1.5초는 가장 느린 수집 주기를 가진 프로토콜(BACnet 계열)의 한 폴링 사이클을 온전히 담을 수 있는 최소 단위였고, 실제 p99 이벤트 루프 지연(38.6ms)에도 여유를 남기는 값이었습니다. 결국 “DB 부하”와 “화면 체감 지연” 사이에서 실측을 통해 찾은 균형점입니다.

poller와 API를 분리한 뒤 실제로 좋아진 것

수집(poller)과 API 프로세스는 별도 프로세스로 분리했습니다. 폴링은 수백~수천 개의 디바이스와 장시간 연결을 유지해야 하는 작업이라, 여기서 발생하는 부하나 일시적인 장애(디바이스 응답 지연, 재접속 시도)가 API 응답성에 그대로 전이되면 안 된다고 판단했기 때문입니다.

분리 이후 체감한 가장 큰 변화는 배포였습니다. poller 로직만 수정해도 API를 재시작할 필요가 없어졌고, 반대로 API를 배포할 때 수집이 끊기는 일도 없어졌습니다. 실제로 조회 시점 기준 11.6일 연속 무중단 가동을 유지할 수 있었던 배경에도 이 분리 구조가 있습니다.

Updated: