You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
공지 등록, 프로젝트 초대 등 도메인 이벤트가 발생하면, Transactional Outbox 패턴과 Redis Streams 기반 메시지 파이프라인을 통해 알림을 비동기로 생성하고 실시간(WebSocket)으로 전달하는 기능을 도입한다.
🎯 배경 / 목적
도메인 서비스(notice, project 등)가 알림 테이블에 직접 insert하는 방식으로 구현하면, 아래와 같은 문제가 생긴다.
도메인 로직과 알림 로직의 책임이 섞임
알림 대상이 여러 명일 때 일부만 저장되고 실패하는 부분 실패 상황에 취약
DB와 메시지 브로커에 각각 쓰는 경우 발생하는 이중 쓰기(Dual Write) 문제로 알림이 유실될 수 있음
이를 해결하기 위해 도메인 트랜잭션과 원자적으로 묶이는 Outbox 테이블에 이벤트를 기록하고, 별도의 릴레이/컨슈머 프로세스가 이를 Redis Stream을 통해 안전하게 처리하도록 분리한다.
또한 알림 도메인이 브로커 발행/구독 로직에만 의존하도록 격리해, 추후 다른 이벤트 타입이나 다른 브로커(Kafka 등)로 확장/교체가 쉬운 구조를 만든다.
📦 범위
포함:
outbox_events, notification_recipients 테이블 추가 + notifications.event_type 컬럼 추가 (Alembic 마이그레이션)
이벤트 발행 진입점(NotificationEvents) 및 Transactional Outbox 기록 로직
Outbox → Redis Stream 릴레이 백그라운드 프로세스 (outbox_relay)
Redis Stream Consumer Group 기반 알림 생성 컨슈머 (consumer)
회원별 실시간 알림 push (Redis Pub/Sub + WebSocket)
알림 REST API: 목록 조회, 안읽음 개수 조회, 단건/전체 읽음 처리
알림 WebSocket 엔드포인트 (/notifications/ws)
공지 등록 시 프로젝트 멤버 전원에게 알림 팬아웃 연동
프로젝트 초대 생성 시 초대 대상자에게 알림 연동
제외:
work(작업) 생성/마감 등 다른 도메인 이벤트 연동
이메일, 푸시(FCM) 등 WebSocket 외 다른 채널로의 알림 발송
재시도 상한 처리 및 Dead Letter Queue
이벤트 중복 처리 방지를 위한 멱등성(Idempotency) 보장
Kafka 등 다른 메시지 브로커로의 교체
✅ 수용 기준 (Acceptance Criteria)
공지를 등록하면 해당 프로젝트의 모든 멤버(리더 포함)에게 알림이 생성된다.
프로젝트 초대장을 생성하면 초대받은 회원 1명에게 알림이 생성된다.
알림 생성은 원본 도메인 트랜잭션(공지/초대 저장)과 분리되어 비동기로 처리되며, Redis 장애 시에도 이벤트가 유실되지 않고 재시도된다.
GET /notifications 로 로그인한 회원의 알림 목록을 페이지네이션 조회할 수 있다.
GET /notifications/unread-count 로 읽지 않은 알림 개수를 조회할 수 있다.
PATCH /notifications/{recipient_id}/read, PATCH /notifications/read-all 로 읽음 처리를 할 수 있다.
WS /notifications/ws?token=... 연결 중이면 새 알림이 실시간으로 push되고, 연결이 없어도 알림은 DB에 남아 REST로 조회 가능해야 한다.
API 서버 인스턴스가 여러 대 떠 있어도 동일 알림 이벤트가 중복 생성되지 않는다 (Consumer Group).
📝 참고 사항
DB 마이그레이션 필요: alembic upgrade head (outbox_events, notification_recipients 테이블 및 notifications.event_type 컬럼 추가)
새 이벤트 타입 추가 시 app/modules/notification/events.py 의 NotificationEvents에 메서드 하나만 추가하면 됨 (예: work.created)
브로커 발행/구독 로직은 outbox_relay.py, consumer.py에만 격리되어 있어 추후 Kafka 등으로 교체 시 도메인 서비스(notice, project) 코드는 변경 불필요
sequenceDiagram
actor U as 사용자(리더)
participant API as Notice Router/Service
participant PG as Postgres
participant Relay as Outbox Relay<br/>(백그라운드)
participant Stream as Redis Stream
participant Consumer as Notification Consumer<br/>(백그라운드)
participant PubSub as Redis Pub/Sub
participant WS as 팀원의 브라우저<br/>(WebSocket)
U->>API: POST /project/{id}/notice
API->>PG: INSERT notices
API->>PG: INSERT outbox_events (event_type=notice.created)
Note over API,PG: 하나의 트랜잭션으로 commit
API-->>U: 201 Created (여기까지가 사용자가 기다리는 부분)
rect rgba(100,100,100,0.08)
Note over Relay,Stream: 여기부터는 비동기 — 사용자는 이미 응답 받음
Relay->>PG: outbox_events 폴링 (dispatched=false)
Relay->>Stream: XADD notifications:stream
Relay->>PG: dispatched=true 로 갱신
end
Consumer->>Stream: XREADGROUP (새 메시지 대기)
Stream-->>Consumer: event payload
Consumer->>PG: INSERT notifications
Consumer->>PG: INSERT notification_recipients (멤버별로)
Consumer->>PubSub: PUBLISH notify:{member_id} (수신자 각각에게)
Consumer->>Stream: XACK
PubSub-->>WS: 실시간 push (그 멤버가 지금 접속해 있다면)
📌 개요
공지 등록, 프로젝트 초대 등 도메인 이벤트가 발생하면, Transactional Outbox 패턴과 Redis Streams 기반 메시지 파이프라인을 통해 알림을 비동기로 생성하고 실시간(WebSocket)으로 전달하는 기능을 도입한다.
🎯 배경 / 목적
도메인 서비스(notice, project 등)가 알림 테이블에 직접 insert하는 방식으로 구현하면, 아래와 같은 문제가 생긴다.
이를 해결하기 위해 도메인 트랜잭션과 원자적으로 묶이는 Outbox 테이블에 이벤트를 기록하고, 별도의 릴레이/컨슈머 프로세스가 이를 Redis Stream을 통해 안전하게 처리하도록 분리한다.
또한 알림 도메인이 브로커 발행/구독 로직에만 의존하도록 격리해, 추후 다른 이벤트 타입이나 다른 브로커(Kafka 등)로 확장/교체가 쉬운 구조를 만든다.
📦 범위
포함:
제외:
✅ 수용 기준 (Acceptance Criteria)
📝 참고 사항
sequenceDiagram actor U as 사용자(리더) participant API as Notice Router/Service participant PG as Postgres participant Relay as Outbox Relay<br/>(백그라운드) participant Stream as Redis Stream participant Consumer as Notification Consumer<br/>(백그라운드) participant PubSub as Redis Pub/Sub participant WS as 팀원의 브라우저<br/>(WebSocket) U->>API: POST /project/{id}/notice API->>PG: INSERT notices API->>PG: INSERT outbox_events (event_type=notice.created) Note over API,PG: 하나의 트랜잭션으로 commit API-->>U: 201 Created (여기까지가 사용자가 기다리는 부분) rect rgba(100,100,100,0.08) Note over Relay,Stream: 여기부터는 비동기 — 사용자는 이미 응답 받음 Relay->>PG: outbox_events 폴링 (dispatched=false) Relay->>Stream: XADD notifications:stream Relay->>PG: dispatched=true 로 갱신 end Consumer->>Stream: XREADGROUP (새 메시지 대기) Stream-->>Consumer: event payload Consumer->>PG: INSERT notifications Consumer->>PG: INSERT notification_recipients (멤버별로) Consumer->>PubSub: PUBLISH notify:{member_id} (수신자 각각에게) Consumer->>Stream: XACK PubSub-->>WS: 실시간 push (그 멤버가 지금 접속해 있다면)