폴링 데몬 22개 걷어내고 Redis Stream 처음 만져봤다
10년 넘은 커뮤니티 서비스의 키워드 알림 기능을 DB 폴링 데몬 22개 구조에서 Redis Streams 컨슈머 그룹 기반으로 재설계한 회고. 컨슈머 그룹과 PEL, MAXLEN, TTL 등에서 실제로 겪은 함정들과, 알림 지연을 3초대에서 50ms 미만으로 줄인 과정을 정리했다.
category
backend
type
Post
slug
redis-streams-migration
summary
10년 넘은 커뮤니티 서비스의 키워드 알림 기능을 DB 폴링 데몬 22개 구조에서 Redis Streams 컨슈머 그룹 기반으로 재설계한 회고. 컨슈머 그룹과 PEL, MAXLEN, TTL 등에서 실제로 겪은 함정들과, 알림 지연을 3초대에서 50ms 미만으로 줄인 과정을 정리했다.
date
Mar 20, 2025
updatedAt
Sep 17, 2026
tags
Redis Streams
메시지큐
thumbnail
10년 넘은 커뮤니티 서비스의 키워드 알림을 다시 만들면서 배운 것들.XADD가 뭔지도 모르던 상태에서 시작해서, 3초 걸리던 알림을 50ms 아래로 줄이기까지의 기록이다.
시작은 "이걸 어디부터 건드리지"였다
레거시 이관 작업을 하나 맡았다. 대상은 키워드 알림 — 사용자가 "아이유" 같은 단어를 등록해두면, 제목에 그 단어가 들어간 글이 올라올 때 알려주는 기능이다. 커뮤니티 서비스에서는 꽤 핵심 기능인데, 그래서 오히려 더 오래 방치돼 있었다.
코드를 처음 열었을 때 든 생각이 "이걸 어디서부터 건드리지"였다. 그리고 실제로는, 문제를 제대로 짚는 데 걸린 시간이 코드를 짜는 시간보다 길었다. 이 글은 그 과정에서 내가 틀렸던 것들에 대한 기록이다. Redis Stream을 이번에 처음 제대로 써봤고, 그래서 초보가 밟을 수 있는 함정은 거의 다 밟았다.
회사나 서비스를 특정할 수 있는 정보는 전부 일반화했다. 테이블명·파일명은 실제와 다르지만 구조는 그대로다.
무한루프 22개가 돌고 있었다
기존 구조를 그려보면 이랬다.
글이 하나 올라오면(
write.php에서 INSERT), 매칭 데몬이 그걸 잡아채야 하는데, 이 매칭 데몬이 10개 프로세스로 쪼개져 있었다. user_no % 10으로 샤드를 나누고, 각자 DB 커서를 들고 2초마다 활성 키워드 전체를 다시 SELECT해서 키워드 × 사용자 × 글, 3중 루프를 돌았다. 매칭되면 알림함 INSERT, 앱푸시 큐 INSERT, 웹푸시 큐 INSERT, 그리고 실시간 채널로는 별도 서버에 curl을 쏘는 구조였다. 그 뒤엔 발송 데몬 12개가 또 각자 커서로 큐를 폴링하면서 FCM/APNs로 쏴줬다. 브라우저 쪽은 iframe으로 forever-frame 패턴을 써서 부모 함수를 계속 호출하는 방식이었고.숫자로 정리하면:
- 상시 구동 PHP 데몬: 매칭 10 + 앱푸시 2 + 웹푸시 10 = 22개
- 전부 DB를 커서로 폴링하는 무한루프, 1~2초 주기
- 알림 1건의 최소 지연: 매칭 사이클(~2초) + 큐 적재 + 발송 사이클(~1초) ≈ 3초 이상
- 중복 실행 방어:
ps aux | grep+ lock 파일 +posix_kill(SIGHUP), 세 겹
마지막 줄이 특히 인상적이었다. 중복 실행 방어 코드가 세 겹으로 들어가 있다는 건, 그만큼 사고가 났었다는 뜻이니까.
처음엔 "왜 이렇게 복잡하게 샤딩했지" 싶었는데, 코드를 더 읽어보니 이해가 갔다. 이 정적 샤딩은 성능 최적화가 아니라, PHP에 워커 풀이 없어서 생긴 구조였다. 프로세스 하나가 전체를 돌면 사이클이 너무 길어지니까
argv로 10등분해서 병렬화한 거고, 그 결과 커서도 10개로 쪼개진 거다. 당시로서는 합리적인 선택이었다. 그리고 진짜 부하의 원인은 회원 수가 아니라, 매 사이클마다 활성 키워드 전체를 다시 조회하고 3중 루프를 도는 구조 그 자체였다.처음에 잘못 짚었던 방향
초기 계획서에 나는 이렇게 적었다.
"10개 샤드를 단일 프로세스로 통합한다."
리뷰를 받고 나서야 방향을 다시 잡았다. 정리하면 이렇다.
샤딩을 없애는 게 목표가 아니라, 샤딩이 필요했던 이유를 없애는 게 목표다.
샤딩이 필요했던 이유는 폴링이다. 폴링을 없애면 샤딩은 따라서 사라진다. 반대로 폴링은 그대로 둔 채 샤드만 합치면 사이클이 10배로 길어질 뿐이다. 이 문장 하나가 설계 전체를 바꿨다. "새 글이 올라왔을 때 알림을 받는" 구조로 가야 했고, 그러려면 이벤트를 흘려보낼 파이프가 필요했다. 그래서 Redis를 열었다.
명령어부터 막혔다
레퍼런스를 찾아보니
XADD, XREADGROUP, XACK, XAUTOCLAIM, XPENDING, XINFO 같은 것들이 나왔다. 솔직히 처음엔 오타인 줄 알았다. XREADGROUP을 잘못 읽고 한참 검색한 적도 있다.그러다 규칙 하나를 발견하고서 확 편해졌다. Redis 명령은 앞글자가 자료형이다. 접두어 없으면 String(
GET, SET), H는 Hash, L은 List, S는 Set, Z는 Sorted Set, 그리고 X는 Stream. X로 시작하면 전부 Stream 명령이라는 걸 알고 나니, 명령어 30개를 따로 외울 필요가 없어졌다. 자료형 하나만 이해하면 되는 거였다.등기우편과 라디오 방송
Stream을 이해하는 데 제일 도움이 됐던 비유가 있다. Redis Stream은 등기우편이다. 보관되고, 받았다고 서명(
XACK)해야 배송 완료 처리가 된다. 반면 Redis Pub/Sub은 라디오 방송이다. 그 순간 안 듣고 있으면 그냥 사라진다.redis-cli로 직접 해보면 1분 만에 이해된다. 터미널 하나에서 SUBSCRIBE를 걸어두고 다른 터미널에서 PUBLISH를 하면 즉시 뜬다. 근데 구독을 끊은 다음에 다시 PUBLISH를 해보면 반환값이 0으로 온다. 이 반환값은 "받은 구독자 수"인데, 0이 나왔다는 건 아무도 안 듣고 있었고 그 메시지는 그냥 증발했다는 뜻이다. 에러도 아니고 재시도도 없다.이
0을 눈으로 본 순간 왜 Pub/Sub만으로는 알림을 만들 수 없는지 이해됐다. 배포로 서버가 30초 재시작하는 동안 올라온 글은 전부 증발할 테니까.제일 오래 헤맨 것 — 컨슈머 그룹과 PEL
Stream을 그냥 읽는 명령(
XREAD)은, 여러 프로세스가 읽으면 전원이 같은 걸 다 읽는다. 배치 3대가 각자 읽으면 알림이 3번 간다. 컨슈머 그룹을 쓰면 Redis가 "이 그룹에게 어디까지 줬는지"를 기억해주고, 같은 그룹 안의 프로세스들이 서로 다른 엔트리를 나눠 받는다. 그룹을 여러 개 만들면 서로 완전히 독립적으로 동작한다는 것도 한 번 감탄했던 부분이다. 나중에 "새 글로 통계도 내고 싶다"가 생기면 그룹만 하나 더 만들면 되고 기존 배치는 아무 영향이 없다. 레거시가 user_no % 10을 코드에 박아둔 것과 정확히 반대다.PEL(Pending Entries List)은 "배달했지만 아직 처리 완료 확인을 못 받은 목록"이다. 택배로 치면 배송 완료 처리가 안 된 송장 목록.
XREADGROUP으로 엔트리를 받으면 PEL에 등록되고, 애플리케이션이 처리한 뒤 XACK를 호출하면 PEL에서 빠진다. 그 사이에 프로세스가 죽으면 엔트리는 PEL에 남아 있으니 유실되지 않고, XAUTOCLAIM으로 일정 시간 방치된 걸 다른 컨슈머가 가져와서 다시 처리할 수 있다.그런데 이 구조가 보장하는 건 at-least-once(최소 한 번)이지, "정확히 한 번"이 아니라는 걸 한참 뒤에 깨달았다. 재처리로 중복이 생길 수 있다. 그래서 아웃박스 테이블에 유니크 제약을 걸었다.
INSERT IGNORE INTO notification_outbox (...) -- UNIQUE (user_no, type, board_id, board_no)
Stream의 at-least-once와 DB 유니크 제약은 한 쌍으로 설계해야 한다. 둘 중 하나만 있으면 불완전하다. Stream만 있으면 중복 알림이 가고, 유니크 제약만 있으면 유실된다. 이걸 이해하고 나니 "메시지 큐를 쓰면 중복이 안 생긴다"는 내 막연한 생각이 틀렸다는 걸 알았다. 중복은 큐가 아니라 애플리케이션이 막는 것이었다.
실제로 밟은 함정들
여기서부터가 진짜 본론이다.
①
XACK를 처리 전에 불렀다. 받자마자 ACK부터 하고 그 다음에 발송하는 코드를 처음에 짰는데, 리뷰에서 바로 잡혔다. ACK를 먼저 하면 PEL에서 지워지니까, 그 뒤에 죽으면 알림이 영영 안 간다. PEL의 존재 의의가 사라지는 코드였다. 아웃박스에 커밋을 먼저 하고 그 다음에 XACK하는 순서로 고쳤다.② 필터로 걸러진 것도 ACK해야 한다는 걸 몰랐다. 삭제된 글이나 알림 제외 플래그가 붙은 글은 처리 없이 넘기면 되는데, 그냥
continue했다. 그러면 ACK를 안 하니까 PEL에 영원히 남는다. "처리할 게 없다"는 판정도 처리의 일종이라서, ACK해서 PEL을 비워줘야 한다. 같은 이유로 예외가 나서 ACK를 건너뛰는 경로도 조심해야 한다. 실패 시엔 재시도 횟수를 세서 한도를 넘으면 DLQ 스트림으로 옮기고 ACK하는 게 정석이라고 한다.③
>와 0의 차이를 몰랐다. XREADGROUP의 마지막 ID 자리에 >를 넣으면 "이 그룹의 누구에게도 배달된 적 없는 새 엔트리"고, 0을 넣으면 "내가 받았지만 아직 ACK 안 한 것들 = 내 PEL"이다. 처음엔 >만 썼는데, 그러면 재시작했을 때 죽기 전에 받아둔 엔트리를 못 본다. XAUTOCLAIM의 idle 시간(60초)을 기다려야만 회수된다. 그래서 부팅 직후엔 0으로 내 PEL부터 비우고, 그 다음부터 >로 새 일감을 받는 순서로 고쳤다.④ 구독 커넥션에서
GET을 날렸다. 구독 중인 커넥션에서는 다른 명령을 쓸 수 없다. SUBSCRIBE 상태에 들어간 커넥션은 구독 관련 명령과 PING/QUIT 정도만 받아준다. 실행하자마자 에러가 나서 금방 알아챘지만, 왜 그런지는 한참 뒤에 이해했다. 그래서 커넥션을 역할별로 세 개로 나눴다 — 커맨드용(XADD/XREADGROUP/XACK/SET NX), 발행용(PUBLISH), 구독용(SUBSCRIBE). 발행용을 굳이 따로 둔 건 프로토콜상 필수는 아니고, 발행이 커맨드 커넥션의 큐 뒤에 줄 서지 않게 하고 역할별로 지표를 분리해 보려는 의도였다.⑤
MAXLEN에 ~를 안 붙였다. Stream은 그냥 두면 영원히 커지니까 상한이 필요한데, MAXLEN 100000(정확)은 초과분을 하나씩 정확히 지우느라 느리고, MAXLEN ~ 100000(근사)은 Redis가 성능상 이득이 없어지는 지점에서 트리밍을 조기 종료해서 훨씬 빠르다. 처음엔 이게 뭔지 몰라서 정확 옵션으로 뒀는데, 이게 글쓰기 경로에 얹히는 명령이라는 게 중요하다. 여기서 느려지면 사용자가 글 등록 버튼을 누르고 기다리게 된다. 정확히 10만 개일 필요가 전혀 없는데 비싼 연산을 할 이유가 없다.⑥
MAXLEN이 미처리 엔트리도 자른다는 걸 몰랐다. 이건 좀 더 무서운 함정이다. MAXLEN은 처리 여부를 보지 않는다. 아직 아무도 안 읽은 엔트리도 오래됐으면 잘려나간다. 배치가 오래 죽어 있어서 밀리면 그 글들의 알림은 영영 안 간다. 그래서 폴백 폴러를 남겨뒀다. 30초마다 커서 기준으로 원본 테이블을 재조회하는, 레거시와 똑같은 방식의 안전망이다. 처음엔 "이벤트로 갈 건데 폴러를 왜 남겨?" 싶었는데, 이제는 이게 Redis 장애용만이 아니라 MAXLEN 절단에 대한 보험이라는 걸 안다.⑧
BLOCK 0을 썼다. BLOCK 0은 "새 엔트리가 올 때까지 영원히 대기"인데, 효율적으로 보였지만 배포할 때 프로세스가 안 죽는 문제가 생겼다. 종료 시그널을 확인할 기회가 없었기 때문이다. BLOCK을 아예 안 쓰면 반대 문제가 생긴다 — 읽을 게 없을 때 즉시 nil이 반환되고 루프가 CPU를 태우며 헛돈다. BLOCK 2000으로 바꿨다. "2초 동안은 새 글이 들어오면 즉시 깨워주고, 안 들어오면 2초 뒤 돌아가서 종료 플래그를 확인하라"는 뜻이다. 여기서 중요한 건 Redis가 깨워준다는 것 — 이건 폴링이 아니다. 레거시가 sleep(1)로 DB를 계속 두드리던 것과 근본이 다르다.⑧ 하마터면 — Stream의 TTL. 이건 실제로 터지진 않았지만 문서를 정리하다 발견하고 식은땀이 났다. 사용자별 알림 백로그를 Stream으로 두고 TTL 7일을 걸어뒀는데, Redis의 TTL은 키 전체에 걸리고
XADD는 기존 TTL을 갱신하지 않는다. 값을 교체하지 않고 내용만 바꾸는 연산(XADD, LPUSH, HSET 등)은 전부 TTL을 건드리지 않는다. 만약 스트림 생성 시 EXPIRE를 한 번만 걸었다면, 매일 알림을 받는 활발한 사용자라도 정확히 7일 뒤 백로그가 통째로 사라진다. "마지막 쓰기로부터 7일"이 아니라 "EXPIRE 호출로부터 7일"이기 때문이다. 7일 뒤에 조용히 터지는 종류의 버그라 로컬에서는 절대 못 잡는다. 그래서 매번 XADD 다음에 EXPIRE를 갱신하는 코드로 고쳐뒀다.설계하면서 배운 것 세 가지
함정 말고, 구조를 잡으면서 "아 이래서 이렇게 하는구나" 싶었던 것들이다.
첫째, 세션 Redis에 Stream을 얹으면 안 된다. 처음 계획은 기존 Redis에 db 인덱스만 나눠 쓰자는 거였다. 인스턴스를 새로 띄우려면 인프라 작업이 필요하니까. 근데 기존 Redis 설정을 확인해보니
maxmemory-policy volatile-lru였다. 이건 TTL이 설정된 키 중에서 가장 오래 안 쓴 걸 제거하는 정책인데, 세션 키는 전부 TTL이 있다. 즉 Stream이 메모리를 밀어 올리면 가장 먼저 죽는 게 세션 키다. 알림 발송이 급증하는 순간 전 사용자가 로그아웃되는 거다. SELECT로 db를 나눠도 소용없다. db 인덱스는 논리적 네임스페이스일 뿐이고, maxmemory, 단일 스레드 CPU, 버퍼 제한, persistence 설정은 전부 인스턴스 전역으로 공유된다. 결국 처음부터 별도 프로세스로 띄웠고, maxmemory-policy noeviction으로 설정을 다르게 가져갔다. noeviction은 메모리 한도에 닿으면 키를 밀어내지 않고 XADD를 실패시키는 정책이다. 그러면 폴백 폴러가 주워 담는다. 조용한 유실보다 시끄러운 실패가 낫다 — 이 판단이 이번 작업에서 제일 마음에 드는 부분이다.둘째, Pub/Sub과 Stream을 겹쳐 쓴다. 처음엔 "Pub/Sub 하나면 되는 거 아닌가" 싶었는데, 최종 구조는 Stream에 먼저 쌓고 그 다음 Pub/Sub으로 알리는 방식이 됐다.
PUBLISH는 빠르지만 유실된다 — 지하철에서 연결이 끊긴 사이의 알림은 증발한다. XADD한 백로그는 느리지만 남는다 — 재연결 시 Last-Event-ID 이후를 XRANGE로 되감아 재전송할 수 있다. 순서도 중요한데, 반드시 XADD가 먼저여야 한다. 반대로 하면 클라이언트가 알림을 받고 백로그를 조회했는데 아직 안 들어와 있는 경합이 생긴다. 재밌는 건 완전히 다른 곳에서도 같은 패턴이 나왔다는 거다. 키워드 CRUD가 일어나면 배치의 인메모리 인덱스를 갱신해야 하는데, INCR로 버전 번호를 올려서 남기고, PUBLISH로 "지금 리빌드해"를 빠르게 알리는 식으로 두 개를 같이 썼다. Pub/Sub을 놓쳐도 5초마다 버전을 폴링해서 세대가 다르면 리빌드하는 안전망이 잡아준다. 빠르지만 믿을 수 없는 즉시 전달과, 느리지만 믿을 수 있는 보관을 겹쳐서 속도와 안전성을 동시에 얻는 패턴이었다.셋째, 락은 중복 방지용이 아니었다. 배치에 리더 락을 걸었다.
SET lock:keyword-matcher <토큰> NX PX 30000 — 반드시 한 명령이어야 한다. SETNX 후 EXPIRE를 따로 하면, 그 사이에 프로세스가 죽었을 때 TTL 없는 락이 영원히 남아 배치 전체가 멈춘다. 해제도 단순 DEL로 하면 안 된다. 내 락이 TTL로 만료된 뒤 다른 프로세스가 잡은 락을 내가 지워버리는 사고가 날 수 있어서, Lua 스크립트로 "내 것인지 확인 → 삭제"를 원자적으로 처리했다. 그런데 문서를 정리하다가 뒤늦게 깨달은 게 있다. 이 락은 중복 처리 방지용이 아니다. 컨슈머 그룹이 이미 중복 배달을 막아주고 있으니까. 락의 실제 목적은 키워드 역색인(Aho–Corasick)을 프로세스마다 빌드하는 낭비를 막는 것, 그리고 인스턴스가 실수로 늘었을 때를 방어하는 것이었다. 즉 자원 절약이지 정합성 장치가 아니었다. 그래서 나중에 유입량이 늘면 락을 풀고 컨슈머를 늘리는 것만으로 수평 확장이 된다. 컨슈머 그룹이 알아서 나눠주니까. 레거시가 user_no % 10을 코드에 박아둔 것과 대비되는 지점이다. 샤딩 축을 코드에 박지 않으면 확장은 설정 변경이 된다.결과
지연 예산을 비교하면 이렇다.
구간 | As-Is | To-Be |
글 등록 → 매칭 시작 | 최대 2초 (배치 사이클) | ~5ms ( XADD → XREADGROUP BLOCK) |
매칭 | 3중 루프 (키워드 수 비례) | 제목 1회 스캔 (Aho–Corasick) |
알림함 → 브라우저 | ~1초 (forever-iframe 폴링) | ~10ms ( PUBLISH → SSE) |
총 체감 (탭 열린 상태) | 3초 이상 | 50ms 미만 |
프로세스도 22개에서 크게 줄었고, 중복 실행을 방어하던 코드도 사라졌다. 근데 숫자보다 구조가 달라진 게 더 크다고 느낀다. 소비자를 늘리거나 줄여도 코드를 안 고쳐도 되고(컨슈머 그룹), "새 글로 통계도 내자"가 생기면 그룹 하나만 추가하면 되고, 실시간 채널이 회원번호로 서버에 고정되지도 않는다.
아직 안 끝났다
솔직히 지금 구조에도 이미 보이는 문제가 있다. 매칭 → 아웃박스 INSERT → 발송(채널별 순차) →
XACK → 다음 읽기로 이어지는 흐름에서, 발송이 오래 걸리면 XACK가 늦어지고, XACK가 늦어지면 새 글도 못 읽는다. 전형적인 head-of-line blocking이다. 건당 DB 왕복이 6~7회 발생하는데, 왕복 0.5ms만 잡아도 건당 5ms, 이론 상한이 초당 200건 정도다. 피크 추정치가 초당 230건이니 이미 못 넘긴다.이건 Redis 명령의 문제가 아니라 루프 구조의 문제다. 그래서 다음 단계는 "인입과 발송을 분리"하는 거다. 매칭까지만 하고 즉시
XACK한 다음, 발송은 별도 큐로 넘기는 방향을 보고 있다. 처음엔 "Redis Stream을 썼으니 큐는 필요 없겠지"라고 생각했는데, Stream은 이벤트 전달용이고 작업 큐는 또 다른 문제더라. 재시도·백오프·DLQ를 직접 구현하느니 전용 라이브러리를 쓰는 게 낫다는 판단이다.정리
Redis를 처음 제대로 써보면서 배운 걸 세 줄로 줄이면 이렇다.
첫째, Redis는 캐시가 아니라 자료구조 서버다.
GET/SET만 쓰면 성능의 절반도 못 쓴다. 랭킹·큐·중복제거·레이트리미팅이 전부 "자료구조 서버"라는 정체성에서 나온다.둘째, 유실과 중복은 트레이드오프고, 어느 쪽을 택했는지 알고 있어야 한다. Pub/Sub은 유실을 택한 거고, Stream은 중복을 택한 거다. Stream을 썼으면 멱등성은 애플리케이션이 책임져야 한다.
셋째, 레거시의 이상한 구조에는 대체로 이유가 있다.
user_no % 10 샤딩은 게으름이 아니라 PHP에 워커 풀이 없던 시절의 합리적 선택이었다. 그 이유를 이해해야 뭘 없애도 되는지가 보인다. 나는 처음에 "샤딩을 없애자"고 적었다가, "폴링을 없애면 샤딩은 따라서 사라진다"로 고쳐야 했다.그리고 하나 더 — 명령어 30개를 외우는 것보다 자료구조 하나를 이해하는 게 빠르다.
X가 Stream이라는 걸 아는 순간 나머지는 조합이었다.처음 만질 때 이것만은
직접 밟아보고 정리한 체크리스트다.
XACK는 처리 성공 뒤에. 필터로 걸러진 것도 ACK할 것
- 부팅 시
0으로 내 PEL부터 비우고, 그 다음>로 새 일감
MAXLEN에는~를 붙일 것 (쓰기 경로 지연 직결)
MAXLEN은 미처리 엔트리도 자른다 → 안전망(폴백 폴러)을 남길 것
BLOCK 0금지. 2초 정도로 두고 루프마다 종료 플래그 확인
- Stream에 TTL을 걸었다면
XADD마다EXPIRE갱신
- 구독용 커넥션은 반드시 분리
- 세션 Redis와 절대 합치지 말 것
- 모니터링에
XPENDING건수와XINFO GROUPS의lag올리기
마지막 항목이 특히 중요하다. 이 두 숫자가 배치 건강 상태의 거의 전부다.
XPENDING이 계속 오르면 어딘가 ACK가 안 되고 있는 거고, lag이 계속 오르면 유입 속도를 못 따라가는 거다.다음 글에서는 SSE로 실시간 알림을 붙이면서 겪은 것들 —
Last-Event-ID 복구, nginx 버퍼링, 그리고 "연결의 수명이 세션의 수명보다 길다"는 문제를 다뤄보려 한다.