알림 시스템 설계: push, email, SMS 재시도 및 우선순위
1. 개요
거의 모든 제품에는 알림 전송이 필요합니다. SMS를 통한 OTP 코드, 주문 확인 이메일, 새 메시지 도착 시 푸시입니다. 하나의 알림을 보내는 것은 쉽습니다. 어려운 것은 수백만 개의 알림을 손실 없이, 중복 없이, OTP가 마케팅 이메일 뒤에 대기하지 않도록 보내는 것입니다.
이 글에서는 처음부터 끝까지 다채널 알림 시스템을 설계합니다. 범위는 요구사항, 데이터 모델, API, 처리 흐름, 그리고 어려운 점들을 푸는 방법을 포함합니다: 중복 전송, DB와 Kafka 사이의 일관성, 트래픽 급증. 코드 예시는 Java 21, Spring Boot 3.x, PostgreSQL, Kafka, Redis를 사용합니다.
2. 요구사항
2.1. 기능 요구사항
- Order, Auth, Marketing 같은 다른 서비스들이 API를 호출하여 사용자에게 한 개 이상의 채널을 통해 알림을 전송할 수 있습니다: PUSH, EMAIL, SMS.
- 각 알림은 우선순위를 가집니다:
HIGH(OTP, 보안 경고),NORMAL(거래),LOW(마케팅). - 시스템은 제공자 일시적 오류 시 자동으로 재시도합니다. 최대 횟수 후, 알림은
FAILED로 표시되어 조회할 수 있습니다. - 시스템은 사용자 선호도를 존중합니다. 예를 들어 사용자가 마케팅 이메일을 비활성화한 경우입니다.
2.2. 규모 및 제약
- 약 5천만 건의 알림/일. 평균 약 600/초, 마케팅 캠페인 실행 시 피크는 초당 20,000에 도달할 수 있습니다.
HIGH우선순위 알림은 피크 시간에도 몇 초 내에 전송되어야 합니다.- SMS/이메일 제공자는 속도를 제한합니다. 예를 들어 각 API 키당 초당 500개 요청.
- 인프라 수준의 전달 의미는 at-least-once이지만, 합리적인 수준에서 중복을 방지해야 합니다. 사용자가 같은 OTP를 두 번 받는 것은 좋지 않은 경험입니다. 같은 SMS를 두 번 받는 것은 비용이 듭니다.
3. 전체 아키텍처
Client service ──POST──▶ Notification API ──(1 transaction)──▶ PostgreSQL
│ notification, delivery (PENDING)
▼
Outbox Relay (poll, SKIP LOCKED)
│ publish by priority
┌───────────────────────────────┼───────────────────────────┐
▼ ▼ ▼
notifications.high notifications.normal notifications.low
│ │ │
└────────────▶ Delivery Workers ◀───────────────────────────┘
│ dedup (Redis) + rate limit (Redis)
▼
FCM/APNs · Email provider · SMS provider
│
success → SENT │ transient error → PENDING + next_attempt_at
핵심 아이디어: delivery 테이블이 진실의 원천입니다. Kafka는 단지 운송 수단입니다. 재시도는 별도의 재시도 topic이 필요하지 않습니다. Worker는 단지 레코드를 미래의 next_attempt_at과 함께 PENDING으로 설정하면, Outbox Relay가 시간이 되면 자동으로 다시 전송합니다.
4. 데이터 모델
CREATE TABLE notification (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(100) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
template VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE delivery (
id UUID PRIMARY KEY,
notification_id UUID NOT NULL REFERENCES notification(id),
channel VARCHAR(10) NOT NULL, -- PUSH | EMAIL | SMS
priority SMALLINT NOT NULL, -- 0 HIGH, 1 NORMAL, 2 LOW
status VARCHAR(10) NOT NULL, -- PENDING | QUEUED | SENT | FAILED
attempts INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
UNIQUE (notification_id, channel)
);
CREATE INDEX idx_delivery_due
ON delivery (priority, next_attempt_at)
WHERE status = 'PENDING';
주목할 점:
idempotency_key UNIQUE는 클라이언트가 같은 알림을 여러 번 생성하는 것을 방지합니다.- 각 채널은 별도의
delivery행입니다. 이메일 오류가 푸시 재전송을 강요하지 않습니다. priority는 Relay의 쿼리에 맞게delivery테이블에도 반복됩니다. partial indexidx_delivery_due는PENDING행만 포함하므로, 테이블에 수십억 개의SENT행이 있어도 항상 작습니다.
5. 알림 수신 API
먼저 필요한 dependency를 선언합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
그 다음 API입니다. 클라이언트는 반드시 Idempotency-Key 헤더를 보내야 합니다. 보통 그것은 비즈니스 ID입니다. 예를 들어 order-123-confirmed.
public enum Priority { HIGH, NORMAL, LOW }
public enum Channel { PUSH, EMAIL, SMS }
public record SendRequest(long userId, String template, Map<String, Object> payload,
Priority priority, Set<Channel> channels) {}
@RestController
@RequestMapping("/api/notifications")
class NotificationController {
private final NotificationService service;
NotificationController(NotificationService service) { this.service = service; }
@PostMapping
ResponseEntity<Map<String, UUID>> send(@RequestHeader("Idempotency-Key") String key,
@RequestBody SendRequest req) {
UUID id = service.accept(key, req);
return ResponseEntity.accepted().body(Map.of("id", id));
}
}
API는 202 Accepted를 반환합니다. 왜냐하면 전송이 비동기로 이루어지기 때문입니다. 실제 처리는 service에 있습니다:
@Service
class NotificationService {
private final JdbcTemplate jdbc;
private final ObjectMapper mapper;
NotificationService(JdbcTemplate jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
@Transactional
public UUID accept(String key, SendRequest req) {
List<UUID> inserted = jdbc.queryForList("""
INSERT INTO notification (id, idempotency_key, user_id, template, payload)
VALUES (?, ?, ?, ?, ?::jsonb)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id
""", UUID.class, UUID.randomUUID(), key, req.userId(), req.template(), toJson(req.payload()));
if (inserted.isEmpty()) { // 중복된 요청 → 이전 알림 반환
return jdbc.queryForObject(
"SELECT id FROM notification WHERE idempotency_key = ?", UUID.class, key);
}
UUID id = inserted.getFirst();
for (Channel ch : req.channels()) {
jdbc.update("""
INSERT INTO delivery (id, notification_id, channel, priority, status)
VALUES (?, ?, ?, ?, 'PENDING')
""", UUID.randomUUID(), id, ch.name(), req.priority().ordinal());
}
return id;
}
private String toJson(Map<String, Object> payload) {
try {
return mapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("payload가 유효하지 않습니다", e);
}
}
}
ON CONFLICT ... DO NOTHING RETURNING id는 데이터베이스에서 경쟁을 처리합니다. 같은 키로 두 요청이 동시에 올 때, 한 요청만 insert에 성공합니다. 나머지 요청은 빈 목록을 받고 이전 ID를 다시 읽습니다. 애플리케이션 수준의 lock이 필요하지 않습니다.
중요한 점: service는 이 transaction 내에서 Kafka를 보내지 않습니다. 이것이 Transactional Outbox 패턴입니다. 다음 섹션에서 설명됩니다.
6. Outbox Relay와 우선순위 큐
클라이언트가 DB에 쓰고 나서 Kafka로 보낸다고 가정해봅시다. 애플리케이션이 두 단계 사이에 죽으면, 알림은 DB에 있지만 절대 전송되지 않습니다. 반대로 Kafka를 먼저 보내고 DB에 쓰면, worker는 아직 존재하지 않는 레코드를 처리할 수 있습니다. Outbox는 이 문제를 해결합니다: DB가 유일한 쓰기 장소이고, Relay가 DB에서 읽고 나서 publish합니다.
@Component
class OutboxRelay {
private static final String[] TOPICS =
{"notifications.high", "notifications.normal", "notifications.low"};
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<Map<String, Object>> due = jdbc.queryForList("""
SELECT id, priority FROM delivery
WHERE status = 'PENDING' AND next_attempt_at <= now()
ORDER BY priority, next_attempt_at
LIMIT 500
FOR UPDATE SKIP LOCKED
""");
for (Map<String, Object> row : due) {
UUID id = (UUID) row.get("id");
int priority = ((Number) row.get("priority")).intValue();
kafka.send(TOPICS[priority], id.toString(), id.toString()).join();
jdbc.update("UPDATE delivery SET status = 'QUEUED' WHERE id = ?", id);
}
}
}
위 코드는 세 가지를 합니다:
ORDER BY priority, next_attempt_at는HIGH행을 먼저 가져옵니다. 500만 개의 마케팅 이메일이 대기 중이더라도, 새로운 OTP는 다음 poll에서 선택됩니다.FOR UPDATE SKIP LOCKED는 여러 Relay 인스턴스를 병렬로 실행할 수 있게 합니다. 각 인스턴스는 다른 배치를 lock하고 다른 인스턴스가 잡고 있는 행을 건너뜁니다. 어떤 인스턴스도 다른 인스턴스를 기다릴 필요가 없습니다..join()은 broker 확인을 기다린 후 상태를 변경합니다. Relay가 보낸 후 commit 전에 죽으면, 행은 여전히PENDING이고 재전송됩니다. 이것이 at-least-once입니다. Worker는 중복을 방지해야 합니다. 섹션 7.2를 참조하세요.
우선순위는 별도 topic과 별도 concurrency를 통해 consumer 쪽에서도 다시 보장됩니다:
@Component
class DeliveryListeners {
private final DeliveryWorker worker;
DeliveryListeners(DeliveryWorker worker) { this.worker = worker; }
@KafkaListener(topics = "notifications.high", groupId = "delivery", concurrency = "12")
void high(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }
@KafkaListener(topics = "notifications.normal", groupId = "delivery", concurrency = "6")
void normal(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }
@KafkaListener(topics = "notifications.low", groupId = "delivery", concurrency = "2")
void low(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }
}
Kafka는 한 topic 내에 우선순위 큐가 없습니다. 따라서 topic을 분리하는 것이 가장 실용적입니다. 마케팅 캠페인이 notifications.low에서 수백만 메시지를 대기시키고 있어도, notifications.high의 consumer는 별도 thread에서 실행되며 영향을 받지 않습니다. concurrency 숫자는 각 topic의 partition 수 이하여야 합니다.
7. Worker 전송 및 재시도
7.1. 재시도 정책
모든 오류를 재시도해야 하는 것은 아닙니다. Timeout이나 HTTP 429/503은 일시적 오류입니다. 잘못된 전화번호나 취소된 푸시 토큰은 영구적 오류이며, 재시도는 돈만 낭비합니다.
public class RetryPolicy {
private final Duration base;
private final Duration max;
private final int maxAttempts;
public RetryPolicy(Duration base, Duration max, int maxAttempts) {
this.base = base;
this.max = max;
this.maxAttempts = maxAttempts;
}
/** attempt: 이미 시도한 횟수 (1부터 시작). Empty = 재시도 중지. */
public Optional<Duration> nextDelay(int attempt) {
if (attempt >= maxAttempts) return Optional.empty();
long millis = base.toMillis() * (1L << Math.min(attempt - 1, 20));
return Optional.of(Duration.ofMillis(Math.min(millis, max.toMillis())));
}
}
base = 2s, max = 5m, maxAttempts = 6일 때, 대기 간격은 다음과 같습니다:
1번 시도 실패 → 2초 대기
2번 시도 실패 → 4초 대기
3번 시도 실패 → 8초 대기
4번 시도 실패 → 16초 대기
5번 시도 실패 → 32초 대기
6번 시도 실패 → FAILED
production에서는 대기 간격에 무작위 jitter를 더해야 합니다. 그렇지 않으면, provider가 다운되었을 때 같은 오류로 인한 수천 개의 알림이 정확히 같은 시간에 재시도합니다. 이 현상을 thundering herd라고 합니다.
이 정책을 간단한 테스트로 확인할 수 있습니다:
class RetryPolicyTest {
private final RetryPolicy policy =
new RetryPolicy(Duration.ofSeconds(2), Duration.ofSeconds(10), 6);
@Test
void backoffDoubles() {
assertThat(policy.nextDelay(1)).contains(Duration.ofSeconds(2));
assertThat(policy.nextDelay(3)).contains(Duration.ofSeconds(8));
assertThat(policy.nextDelay(4)).contains(Duration.ofSeconds(10)); // max로 제한됨
}
@Test
void stopsRetryingAtMaxAttempts() {
assertThat(policy.nextDelay(6)).isEmpty();
}
}
7.2. Worker: 중복 방지, 속도 제한, 상태 업데이트
public class TransientSendException extends RuntimeException {
public TransientSendException(String msg) { super(msg); }
}
public interface ChannelSender {
Channel channel();
void send(UUID deliveryId); // 일시적 오류 시 TransientSendException 발생
}
@Service
class DeliveryWorker {
private final JdbcTemplate jdbc;
private final StringRedisTemplate redis;
private final Map<Channel, ChannelSender> senders;
private final RetryPolicy retry = new RetryPolicy(Duration.ofSeconds(2), Duration.ofMinutes(5), 6);
DeliveryWorker(JdbcTemplate jdbc, StringRedisTemplate redis, List<ChannelSender> list) {
this.jdbc = jdbc;
this.redis = redis;
this.senders = list.stream().collect(Collectors.toMap(ChannelSender::channel, s -> s));
}
public void process(UUID id) {
Map<String, Object> d = jdbc.queryForMap(
"SELECT channel, attempts, status FROM delivery WHERE id = ?", id);
if ("SENT".equals(d.get("status"))) return; // Kafka에서 반복된 메시지
Boolean first = redis.opsForValue()
.setIfAbsent("notif:sending:" + id, "1", Duration.ofMinutes(10));
if (!Boolean.TRUE.equals(first)) return; // 다른 worker가 전송 중
Channel channel = Channel.valueOf((String) d.get("channel"));
int attempt = ((Number) d.get("attempts")).intValue() + 1;
try {
if (!acquireRate(channel)) {
throw new TransientSendException("rate limited"); // 연기, provider 오류로 계산하지 않음
}
senders.get(channel).send(id);
jdbc.update("UPDATE delivery SET status='SENT', attempts=? WHERE id=?", attempt, id);
} catch (TransientSendException e) {
redis.delete("notif:sending:" + id);
retry.nextDelay(attempt).ifPresentOrElse(
delay -> jdbc.update("""
UPDATE delivery SET status='PENDING', attempts=?, last_error=?,
next_attempt_at = now() + make_interval(secs => ?)
WHERE id=?""", attempt, e.getMessage(), delay.toSeconds(), id),
() -> markFailed(id, attempt, e.getMessage()));
} catch (RuntimeException e) { // 영구적 오류
markFailed(id, attempt, e.getMessage());
}
}
private void markFailed(UUID id, int attempt, String error) {
jdbc.update("UPDATE delivery SET status='FAILED', attempts=?, last_error=? WHERE id=?",
attempt, error, id);
}
private boolean acquireRate(Channel channel) {
String key = "rate:" + channel + ":" + Instant.now().getEpochSecond();
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
return count != null && count <= 500;
}
}
Worker는 중복 방지를 위해 두 층을 가지고 있습니다:
status = 'SENT'확인은 전송 완료 후 재publish된 메시지를 제거합니다.- TTL이 있는 Redis ‘SET NX’ lock은 Kafka rebalance 중에 두 consumer가 같은 메시지를 받는 경우를 방지합니다. TTL 10분은 worker가 죽은 경우 자동으로 lock을 해제합니다.
이것은 여전히 exactly-once가 아닙니다. Worker가 provider를 성공적으로 호출한 후 UPDATE ... 'SENT' 전에 죽으면, lock이 만료된 후 알림이 재전송될 수 있습니다. 더 엄격히 하려면 deliveryId를 idempotency key로 provider에 전달하세요. 많은 이메일 제공자와 FCM이 이 메커니즘을 지원합니다.
acquireRate는 Redis의 fixed window 속도 제한입니다. 모든 worker 인스턴스가 각 채널당 초당 공유 카운터를 사용하므로, provider의 500 request/초 quota를 넘지 않습니다. 한계를 초과할 때, 알림은 provider에 호출하지 않고 대신 PENDING으로 연기됩니다.
8. 어려운 점과 해결책
8.1. 트래픽 급증
Marketing이 천만 개의 알림을 한 번에 보내면, 시스템은 각 층에서 부하를 받습니다:
- API는 DB에 insert하고 반환만 하므로, 높은 부하를 견딜 수 있습니다. 큰 캠페인의 경우, 사용자 목록 또는 세그먼트를 받는 별도의 batch API를 제공해야 합니다. Background job은 1,000행 단위의 배치로
delivery행을 생성합니다. - Kafka가 나머지를 흡수합니다.
lowtopic의 백로그는 정상입니다.hightopic이 계속 흐르면 됩니다. - Rate limiter가 provider를 보호합니다.
LOW알림이 조금 느리게 전송되어도 아무도 항의하지 않습니다.
한 가지 더: acquireRate에서 HIGH을 위해 quota의 일부를 따로 두세요. 예를 들어 LOW는 한계의 60%만 사용할 수 있게. 이렇게 하면 OTP가 마케팅 이메일 때문에 rate limit되지 않습니다.
8.2. DB와 Kafka 사이의 일관성
Outbox가 at-least-once 의미와 idempotent consumer 결합하면 표준 조합입니다. Distributed transaction (XA)가 필요하지 않습니다. 대신, poll 사이클 하나의 지연이 더 추가됩니다. 여기서는 약 200ms입니다. 더 낮은 지연이 필요하면, polling을 CDC (PostgreSQL WAL 읽기)로 바꾸면 되지만, 인프라가 더 복잡해집니다.
8.3. 데이터 폭증
매일 delivery 테이블에 약 6천만–8천만 행이 추가됩니다. 날짜별로 테이블을 partition (PARTITION BY RANGE (created_at))하고 30–90일 이상 된 partition을 drop하세요. Partial index (섹션 4)가 Relay의 쿼리를 빠르게 유지하지만, 데이터 정리를 대체할 수 없습니다.
8.4. 사용자 선호도 및 조용한 시간
NotificationService에서 delivery를 생성할 때 사용자 선호도를 확인하세요. 사용자가 비활성화한 채널은 행을 생성하지 마세요. Quiet hours의 경우, 예를 들어 밤 10시 이후로 마케팅 푸시를 보내지 않는 경우, next_attempt_at을 다음 날 아침 8시로 설정하면 됩니다. Relay 메커니즘이 나머지를 처리하고, 추가 scheduler가 필요하지 않습니다.
참고: 긴
@Transactional에서 provider를 호출하지 마세요. 30초 느린 HTTP 요청은 DB connection과 row lock을 계속 잡고 있고, 풀이 빠르게 고갈됩니다.DeliveryWorker에서 각UPDATE는 짧고 독립적인 transaction입니다.
참고: 무한 재시도하지 마세요. 영구적 오류를 재시도하지 마세요. 잘못된 전화번호, 6번 재시도, 백만 명의 사용자를 곱하면 매우 큰 SMS 요금이 발생합니다. 각 provider의 오류 코드를 신중하게 분류하세요.
8.5. 이 설계를 사용해야 하는 경우가 아닌 경우
하루에 수천 개의 이메일만 보내면, PostgreSQL과 SKIP LOCKED로 충분합니다. Worker는 delivery 테이블을 직접 poll하고, Kafka는 완전히 버립니다. Kafka는 우선순위별로 consumer를 분리하고 대규모에서 이점을 얻을 때, 또는 다른 여러 시스템이 같은 이벤트 스트림을 소비할 때만 가치 있습니다. 반대로, 같은 사용자의 알림 사이의 절대적인 순서를 보장해야 하면, 위의 설계는 충분하지 않습니다. Kafka key로 userId를 사용하고 비동기 재시도를 해제해야 합니다.
9. 결론
우리는 다음과 같은 주요 결정을 통해 다채널 알림 시스템을 설계했습니다:
delivery테이블이 진실의 원천입니다. 재시도는PENDING을 설정하고next_attempt_at을 지정하는 것입니다.- **Transactional Outbox with
FOR UPDATE SKIP LOCKED**는 분산 transaction 없이 DB와 Kafka를 일관되게 유지하면서, 동시에 여러 Relay 인스턴스를 병렬로 실행할 수 있습니다. - 우선순위는 두 층에서 보장됩니다: SQL에서의 poll 순서와 Kafka에서의 별도 topic + concurrency.
- 다층 중복 방지: API의
Idempotency-Key, worker의 상태 확인 및 Redis lock, provider에 전송하는 idempotency key. - 공유 Redis rate limiter는 트래픽 급증 시 provider의 quota를 보호합니다.
이 틀에서, 다중 언어 template, provider로부터 “배송됨/읽음” 상태를 받는 webhook, 또는 채널별 FAILED 비율을 모니터링하는 dashboard를 추가할 수 있습니다.