알림 시스템에서 가장 먼저 마주하는 문제는 “알림이라는 작업을 어떻게 생성하고 누구에게 전달할 것인가”이다. 특히 하나의 이벤트가 수백만 명에게 전달되어야 하는 상황에서는 단순한 구현으로는 감당할 수 없는 부하가 발생한다. 이에 각 도메인 서비스에서 직접 알림 메시지를 생성하여 처리하기보다 알림 담당 도메인을 두고 event-driven 모델로 통신하는 구조가 적합하다. 이처럼 생성과 전달 책임을 분리하기 위해서 알림은 독립적인 이벤트 형태로 정의할 필요가 있다.
다음으로 고민해야 할 것은 이 이벤트를 어떻게 사용자에게 안정적으로 분배할 지이다. 알림 기능은 대부분 유저 인터렉션을 위한 후속 작업의 성격으로 이용가능한 자원이 제한적이고 알림 이벤트 각각은 서로 독립적이다. 따라서 작업을 최대한 균등하게 분배하고 비동기적으로 처리하는 구조가 핵심이 된다. 따라서 대부분의 대규모 알림 시스템은 Kafka와 같은 외부 시스템에 이벤트를 produce하며 돌입한다.
Kafka가 어떤 구조로 이벤트를 균등하게 분배하고 비동기 처리 구조를 지원하는지 궁금하다면 아래 포스팅 참고
2026.01.22 - [dev/infra] - Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (3)
알림 기능이 어떤 흐름으로 구현되는지 제대로 알아보자.
도메인 이벤트
알림은 항상 어떤 “이벤트”로부터 시작된다. 예를 들어 한 사용자가 게시글에 좋아요를 눌렀다고 가정해보자. 시스템 내부에서는 단순히 "좋아요가 눌렸다"는 DB 상태 변화에 그치지 않고, 이를 계기로 알림 전송이나 행동 로그 기록과 같은 후속 로직이 함께 트리거된다. 이런 구조에서 각 로직을 서로 강하게 결합시키기보다는 의미 있는 사건을 하나의 객체로 정의하고 이를 기반으로 소통하는 방식이 필요하다.
이때 시스템 내에서 발생한 의미 있는 사건을 표현하는 단위가 이벤트이다. 위 예시에서 "사용자가 게시글에 좋아요를 누른 사건"을 도메인 이벤트로 표현하면 다음과 같다.
postLikeEvent
- actor: A (좋아요를 누른 사용자)
- target: B (게시글 작성자)
- content: postId (게시글)
서비스는 내부에서 직접 데이터를 처리하는 대신 이벤트의 형태로 외부로 전달할 수 있다. 좋아요 서비스는 producer로서 이 이벤트를 메시지 브로커에 발행하고 알림 서비스는 consumer로서 이를 구독하여 비동기적으로 처리하는 구조를 통해 알림 로직을 원래 서비스 로직과 분리할 수 있고, 새로운 consumer를 추가하는 것만으로 기능을 확장할 수 있다.
알림 이벤트
알림 시스템에서는 “알림 이벤트”와 “알림 데이터”를 구분해서 이해할 필요가 있다. 알림 이벤트는 특정 도메인 이벤트로부터 파생된 알림 도메인의 이벤트로 어떤 사용자에게 어떤 알림을 생성해야 하는지를 나타내는 중간 단계의 데이터이다. 반면 알림 데이터는 실제 DB에 저장되어 사용자에게 전달되는 최종 결과물로 메시지 형태로 가공되어 저장되고 읽음 상태 등을 포함한다.
만약 위의 postLikeEvent를 감지한 Consumer가 작성자에게 알림을 보내는 로직을 바로 호출한다면 "좋아요 누른 사건" 자체를 나타내는 도메인 이벤트는 알림 이벤트의 역할과 다르지 않다. 알림 서비스가 따로 분리되어있더라도 이 이벤트로부터 직접 알림을 생성해서 유저에게 리턴한다면 postLikeEvent는 도메인 이벤트이자 알림 이벤트가 되고, 생성한 결과물이 알림 데이터가 된다. 이런 구조에서는 postLikeEvent를 매개로 좋아요 도메인과 알림 도메인이 강하게 결합된다.
이처럼 단순한 경우에는 도메인 이벤트가 곧 알림 이벤트로 이어질 수 있지만 현실적인 시스템에서는 아래와 같은 요구사항만 봐도 도메인간 결합도를 낮출 필요가 있기 때문에 두 이벤트를 분리하는 것이 일반적이다.
1. 알림 수신 off인 상황
좋아요 이벤트가 발생하더라도 알림 수신을 꺼놓은 유저에게는 알림을 안 보내도록 해야한다. 만약 postLikeEvent가 곧 알림 이벤트인 구조라면 작성자는 알림 수신을 꺼놓았더라도 좋아요 이벤트에 대해 항상 알림 데이터를 저장하게 되어 비효율적이다.
2. UI용 메시지
도메인 이벤트를 알림 이벤트로 보는 구조에서는 이벤트 하나하나에 알림 데이터가 생성되어 "A 외 99명이 좋아요를 누름"와 같은 aggregation 메시지 구현에 비효율적이다. 따라서 좋아요 기능을 담당하는 도메인과 UI용 결과물을 담당하는 알림 도메인의 책임을 중간에서 분리시키는 독립적인 알림 이벤트의 존재가 필요하다.
3. 하나의 이벤트 → 여러 알림
하나의 도메인 이벤트에 대해 여러 알림을 생성해야 할 경우, 이 작업을 안정적으로 분배하기 위해서 중간 데이터가 필요하다. 앞선 예시에서는 좋아요를 받은 사용자 한 명에게만 알림을 보내면 되기 때문에 도메인 이벤트에서 바로 알림 데이터를 만들어도 큰 문제가 없다. 하지만 팔로워가 많은 사용자가 게시글을 작성했다고 가정해보자.
postCreateEvent
- writerId: 셀럽 유저 id (100만명의 팔로워를 가진 사용자)
- postId: 새로 생성된 게시글 id
100만명의 팔로워를 가진 셀럽이 새 게시글을 작성한 사건을 post 도메인의 이벤트 postCreateEvent라 하자. 모든 팔로워가 이에 대한 알림을 받게 하기 위해서는 수신자 정보가 필요하므로 postCreateEvent의 정보만으로는 부족하다.
postNoficationEvent
- actor: 셀럽 A (100만명의 팔로워를 가진 사용자)
- target: B (A의 팔로워)
- content: postId (새로 생성된 게시글)
따라서 알림 데이터 생성에 필요한 정보를 담은 별도의 알림 이벤트 postNotificationEvent를 정의함으로써 post와 알림 두 도메인의 경계를 보다 명확히 할 수 있다. 그러나 이 경우 서비스는 단순히 하나의 도메인 이벤트 생성으로 끝나지 않고 팔로워 하나하나에 대응하는 100만 개의 알림 데이터를 생성, 처리하는 작업으로 확장된다.
MapReduce 패턴
위에서 봤듯이 무작정 도메인 이벤트와 알림 이벤트를 분리한다고 효율적인 알림 시스템이 완성되지는 않는다. 핵심은 이 이벤트를 “어떻게” 확장할 것인가이다.
MapReduce 패턴은 대용량 데이터를 효율적으로 처리하기 위해 작업을 분할(Map)하고 이를 분산 시스템을 통해 재배치(Shuffle)한 뒤, 병렬로 처리(Reduce)하는 분산 처리 모델이다. 이 패턴은 하나의 무거운 작업을 여러 개의 독립적인 작업 단위로 나누고 이를 여러 노드에서 동시에 처리함으로써 전체 처리 성능을 향상시키는 것을 목표로 한다.
이러한 Map → Shuffle → Reduce 과정은 분산 메시징 시스템을 활용한 fan-out 아키텍처에서 자연스럽게 제공된다. 아래는 Kafka 기반 실시간 이벤트 처리 시스템이 유저마다 서로 다른 알림 이벤트를 생성하는 무거운 반복 작업을 처리하는 과정이다.
[Post Service]
- event: PostCreateEvent
- producer: post-created-topic
↓
[Fanout Service]
- consumer: PostCreateEventConsumer (fanout-group)
- event: FanoutTaskEvent
- producer: FanoutTaskProducer (fanout-task-topic)
↓
[Notification Service]
- consumer: FanoutTaskConsumer (fanout-worker-group)
- event: PostNotificationEvent
- producer: post-notification-topic
먼저 도메인 이벤트 PostCreateEvent를 수신한 Fanout Service는 전체 알림 대상자를 직접 처리하지 않고 offset/limit 기반의 FanoutTaskEvent로 분할한다. 이 단계는 하나의 이벤트를 여러 개의 작업 단위로 나누는 Map 단계에 해당한다.
이후 생성된 FanoutTaskEvent는 Kafka 토픽으로 전송되며 Kafka는 메시지의 key를 기반으로 파티션에 분산 저장하고 이를 여러 컨슈머에게 분배한다. 이 과정은 데이터를 분산 환경으로 재배치하는 Shuffle 단계와 대응된다.
마지막으로 각 컨슈머는 할당받은 FanoutTaskEvent를 처리하면서 해당 범위의 팔로워를 조회하고 사용자별 NotificationEvent를 생성한다. 이는 각 작업 단위를 실제로 처리하며 결과로 변환하는 Reduce 단계에 해당한다.
결과적으로 이 구조는 단일 이벤트를 직접 확장하지 않고 중간에 작업 단위로 분해한 뒤에 분산 처리함으로써 대규모 fan-out을 효율적으로 수행 가능하다.
FanoutTask 이벤트
MapReduce 패턴이 시스템에서 효율적으로 작동하기 위해선 하나의 도메인 이벤트를 적절한 크기의 작업 단위로 분할하는 과정이 핵심이다. 특히 대규모 fan-out 작업의 경우, 단일 이벤트를 직접 처리하는 대신 중간 단계에서 작업을 분해해야 전체 부하를 효과적으로 분산시킬 수 있다.
이를 위해 fan-out 작업용 중간 이벤트인 FanoutTaskEvent가 필요하다. FanoutTaskEvent는 Consumer에게 offset/limit (또는 cursor 기반)으로 처리 범위를 지시함으로써 하나의 도메인 이벤트를 여러 개의 독립적인 처리 단위로 분할하는 역할을 한다. 이렇게 중복없이 분할된 각 작업은 서로 간섭 없이 병렬로 처리될 수 있는 구조를 갖게 된다.
생성된 FanoutTaskEvent는 Kafka 토픽을 통해 전파되며 Kafka는 메시지의 key를 기준으로 파티션에 분산 저장하고 이를 여러 컨슈머에 할당한다. 이 구조는 Map 단계에서 분할된 작업을 Shuffle 단계를 통해 분산 시스템에 재배치하고 이후 각 컨슈머가 독립적으로 작업을 수행하는 Reduce 단계로 자연스럽게 이어진다.
FanoutTaskEvent가 적용된 시스템이 대규모 fan-out을 어떻게 안정적으로 분산 처리하는지 실제 구현을 통해 알아보자.
FanoutTaskEvent
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FanoutTaskEvent {
// 이벤트 식별용 ID
private String eventId; // "postId-offset" 형식 ex) "123-0"
// 원본 도메인 이벤트 정보
private Long postId;
private Long creatorId;
// fanout 범위
private int offset;
private int limit;
private LocalDateTime createdAt;
}
PostCreateEventConsumer
@Component
@RequiredArgsConstructor
public class PostCreateEventConsumer {
private final FollowerRepository followerRepository;
private final FanoutTaskProducer fanoutTaskProducer;
@KafkaListener(
topics = "post-created-topic",
groupId = "fanout-group"
)
public void createFanoutTask(PostCreateEvent event) {
Long creatorId = event.getCreatorId();
// 전체 팔로워 수 조회 (유저 목록 조회 X)
int totalFollowers =
followerRepository.countByCreatorId(creatorId);
// FanoutTaskEvent 생성 + Kafka 전송
fanoutTaskProducer.produce(event, totalFollowers);
}
}
FanoutTaskProducer
@Component
@RequiredArgsConstructor
public class FanoutTaskProducer {
private final KafkaTemplate<String, FanoutTask> kafkaTemplate;
private static final int BATCH_SIZE = 1000; //한 FanoutTaskEvent 당 1000명의 유저 담당
public void produce(PostCreateEvent event, int totalFollowers) {
Long creatorId = event.getCreatorId();
for (int offset = 0; offset < totalFollowers; offset += BATCH_SIZE) {
// FanoutTask 생성
FanoutTaskEvent event = new FanoutTaskEvent(
event.getPostId(),
creatorId,
offset,
BATCH_SIZE
);
// shard 계산
int shard = offset / BATCH_SIZE;
// partition key 생성
String key = creatorId + "-" + shard;
// Kafka로 전송
kafkaTemplate.send(
"fanout-task-topic",
key,
event
);
}
}
}
위의 세 파일은 fan-out 도메인의 서비스로 post 도메인의 이벤트가 알림 도메인을 트리거하는 흐름에서 부하를 분산시키는 중간 과정을 담당한다.
도메인 이벤트인 post-create-topic을 구독한 PostCreateEventConsumer는 PostCreateEvent를 감지하면 fanoutTaskProducer를 호출하여 FanoutTaskEvent를 생성한다. 이때 FanoutTaskEvent는 팔로워 목록 전체를 처리하지 않고 offset, limit를 기반으로 일정 범위의 팔로워를 처리하는 작업 단위 이벤트로 분할된다.
limit가 BATCH_SIZE=1000으로 정의된 FanoutTaskEvent는 팔로워 1000명을 하나의 작업 단위로 묶음으로써 100만명에 대한 알림 이벤트로 확장되기 전에 먼저 1000개의 작업 이벤트로 나눠지게 한다.
FanoutTaskEvent는 “creatorId-shard” 형식의 파티션 키를 사용하여 Kafka의 분산 처리 기능을 활용한다. 이는 fan-out 대상이 creator를 기준으로 조회되는 follower 집합이기 때문에 데이터 접근 패턴과 분산 전략을 일치시키기 위함이다.
하나의 도메인 이벤트가 대량의 알림 이벤트로 바로 확장되기 전에 여러 개의 작업 단위로 나뉘면서 부하가 분산된다.
FanoutTaskConsumer
@Component
@RequiredArgsConstructor
public class FanoutTaskConsumer {
private final FollowerRepository followerRepository;
private final KafkaTemplate<String, PostCreateNotificationEvent> kafkaTemplate;
@KafkaListener(
topics = "fanout-task-topic",
groupId = "fanout-worker-group",
concurrency = "3" // 스레드 3개로 병렬 처리
)
public void createPostNoti(FanoutTaskEvent task) {
// 해당 범위 팔로워 조회
List<Long> followers = followerRepository.findFollowers(
task.getCreatorId(),
task.getOffset(),
task.getLimit()
);
// 범위 내 모든 팔로워에 대한 NotificationEvent 생성
for (Long userId : followers) {
PostCreateNotificationEvent event =
PostCreateNotificationEvent.from(userId, task);
kafkaTemplate.send(
"notification-topic",
userId.toString(), // partition key = userId
event
);
}
}
}
fanout-task-topic을 구독한 FanoutTaskConsumer는 Notification 도메인의 로직으로 fan-out 작업 이벤트를 감지하면 알림 이벤트를 생성한다. 각 브로커에 분산된 1000개의 FanoutTaskEvent를 세 개의 스레드가 병렬 처리하며 한 task당 1000개의 PostCreateNotificationEvent를 생성함으로써 1000개의 작업 이벤트를 100만개의 알림 이벤트로 확장시킨다.
이렇게 확장된 알림 이벤트는 결국 모든 팔로워에 대한 알림 데이터에 대응한다. PostCreateNotificationEvent의 Consumer는 해당 이벤트로부터 알림 데이터를 생성하고 DB에 저장하는 작업 100만 개를 처리한다. 물론 이 작업은 Kafka에 의해 안정적으로 분배되고 비동기 처리 가능하다.

위는 postCreateEvent 1개가 1000개의 FanoutTaskEvent로 분할 후, 100만개의 PostNotificationEvent로 fan-out하는 과정이다. PostNotificationEvent의 Consumer가 알림 이벤트로부터 알림 데이터를 생성하여 DB에 저장하는 과정은 생략했다.
FanoutTask 크기 설정
fan-out 결과로 생성되는 최종 이벤트 개수를 N, fan-out의 stage 수를 t라고 할 때, 각 단계에서의 fan-out 크기를 균등하게 분할하고 각 단계의 작업 크기를 N^{1/t}로 설정하는 것이 연산 비용 측면에서 가장 효율적이다.
전체 fanout 과정을 하나의 트리 구조로 보았을 때 각 단계의 branching factor를 동일하게 유지하여 모든 단계의 부하를 균등하게 분산시키는 방식
위 예시에서는 최종적으로 100만개의 알림 이벤트를 위해 2번의 fanout 단계를 거치므로 각 FanoutTask 단위를 루트 100만 = 1000으로 설정하였다. 만약 1억개의 알림 이벤트를 위해 세 번의 fanout 단계를 넣는다면 fanout 크기는 100명 단위로 충분히 확장성있는 구현이 가능하다.
1 → 100 → 10,000 → 100,000,000
물론 이는 이론적인 수치로 fan-out 과정을 거치며 발생하는 오버헤드와 DB 및 네트워크 IO 비용, 그리고 무엇보다 공간 비용 때문에 현실에서는 stage를 늘리면서 fan-out을 확장하는 일은 거의 없다.
따라서 무작정 도메인 이벤트, fanout 이벤트, 알림 이벤트를 분리한다고 효율적인 알림 시스템이 완성되지는 않는다. 핵심은 이 이벤트를 “언제” 전달할 것인가이다.
이 지점에서 알림 시스템은 중요한 설계 결정을 내려야 한다. 이벤트를 발생시키는 시점에 미리 모든 사용자에게 알림을 생성할 것인지, 사용자가 조회하는 시점에 필요한 알림을 계산할 것인지이다. 이 선택이 바로 fan-out 전략이며 알림 시스템의 확장성을 결정짓는 핵심 요소가 된다.
통지, 알림, 공지 등을 모두 포괄하는 알림 도메인은 기본적으로 이벤트가 발생하는 시점에 수신 대상자가 이를 조회하도록 하는 것이 주 목적이므로 fanout-on-write 방식이 직관적이다. 그러나 이 구조에 수반하는 비효율과 효율적인 알림 시스템은 이를 어떻게 피해가는지 fan-out 전략에 대해 알아보자.
Fan-out 시점
기본적으로 fan-out은 데이터를 여러 사용자에게 확산시키는 과정으로 여러 사용자에게 보여줄 데이터를 생성하고 전달하는 방식이다. 유튜브를 예로 들면, 만약 구독자가 100만 명인 채널에서 영상을 업로드했을 때 각 구독자에 대해 새 영상 알림을 발송해야한다. 또는 각기 다른 100개의 채널을 구독한 여러 명의 유저가 피드를 새로고침했을 때 각자가 팔로우한 채널에서 새로 올린 영상들이 피드에서 조회돼야한다.
이러한 기능 구현에 있어서 문제는 100만개의 알림 또는 100개의 영상 조합이라는 대용량 데이터를 언제 만들어서 전달할 것인가이다. 이 데이터를 write 시점에 생성할지, read 시점에 생성할지에 따라 fan-out 방식을 구분한다.
개인적으로 fanout-on-read와 fanout-on-write 개념은 ORM에서의 lazy loading과 eager loading 개념이 시스템 아키텍처 수준으로 확장된 사례로 볼 수 있다고 생각한다. 즉 필요한 데이터를 요청 시점에 계산할 것인지(lazy), 미리 계산해 둘 것인지(eager)의 차이와 본질적으로 동일한 문제를 다룬다.
Fanout-on-write (Push 모델)
데이터가 작성되는 시점(Write)에 팔로워의 목록을 확인하여 각 팔로워에게 즉시 전달하는 방식
= 유튜버가 채널에 영상을 업로드하는 순간 모든 구독자에 대해 알림 이벤트를 복제 후 전달
video uploaded 이벤트
↓
Kafka
↓
Consumer가 fan-out 수행(알림 이벤트 produce)
↓
Kafka
↓
Consumer가 알림 로직 수행(DB write)
.
.
.
user 알림 저장
↓
바로 조회
장점
- read query 단순해서 read 비용 매우 작음
- 사용자의 Read 시점에 이미 데이터가 준비되어 있어 조회 속도가 매우 빠름
- Kafka와 결합하여 비동기 처리 가능
단점
- write 비용 매우 큼
- follower 많으면 DB write 과부하 문제 발생
- 스토리지 소모 큼
Fanout-on-read (Pull 모델)
사용자가 요청하는 시점(Read)에 필요한 데이터를 동적으로 수집, 조합하여 보여주는 방식
= 영상 업로드시 원본 데이터 하나만 저장, 팔로워가 피드를 조회하는 순간 팔로잉한 모든 채널에 대해 새 영상을 실시간으로 가져와서 로드
video uploaded
↓
DB에 저장
.
.
.
user가 피드 조회 요청
↓
fan-out (following list 돌며 각 채널에 대해 새 video 집계)
↓
feed 데이터 구성
↓
조회 가능
장점
- write 비용 매우 작음
- 데이터 중복 저장이 없어 스토리지 절약
- 구조 및 구현 단순
단점
- join으로 인한 read 비용이 큼
- 대용량 데이터를 실시간으로 집계하므로 조회 성능이 느려질 수 있음
- query 복잡
fanout-on-read 방식은 사실 유저의 request로 트리거되는 대부분의 도메인에서 활용되는 일반화된 방식이다. Service 로직에서 Repository를 호출하여 복잡한 쿼리로 데이터를 뽑아오는 익숙한 흐름에 녹아있다.
Fanout-on-write vs Fanout-on-read
fanout-on-write, fanout-on-read는 결국 유저가 필요로 하는 데이터를 언제 생성할지에 대한 전략의 차이로 볼 수 있다.
| fanout-on-read | fanout-on-write | |
| 병목 위치 | Read path | Write path |
| 주요 작업 | scan, join, sort | insert, index, replication |
| 주요 자원 | CPU + DB read IO + index scan + sort | storage + disk IO + index write + network |
| 특징 | 조회 시 동적 계산 - 실시간 조인으로 가져오므로 조회 시 느림 - 중복 저장 없으므로 쓰기 시 빠름 |
이벤트 생성 시 선계산 - 중복 저장 비용으로 쓰기 시 무거움 - 조회 이전에 이미 데이터 준비되므로 조회 시 빠름 |
| 장점 | write 가벼움, 스토리지 절약 | read 빠름, 쿼리 복잡도 낮음 |
| 유리한 사례 | read 유연성 중요 + 중복 규모가 큼 | read latency 중요 + 중복 규모가 적음 |
두 방식은 시스템 병목이 read path와 write path 중 어디에 발생할지를 결정하는 트레이드오프로 서비스의 규모와 사용자 특성에 따라 적절히 선택되어야 한다.
실제로 트위터의 피드 시스템은 초기에는 사용자 수와 팔로잉 규모가 작아 fanout-on-read 방식으로도 충분했지만 서비스가 성장하면서 조회 시 조인 비용이 증가하자 일부를 fanout-on-write 방식으로 전환하였다. 그러나 팔로워 수가 매우 많은 셀럽의 경우 DB write 폭발 문제를 유발하므로 최종적으로는 사용자 특성에 따라 fanout-on-read와 fanout-on-write를 혼합한 hybrid 구조로 발전하였다.
Celebrity Problem
기본적인 알림 기능 구현에 있어서는 직관적인 fanout-on-write 방식이 적합하다. 하지만 위에서 봤듯이 write 시점에 심각한 병목을 만들 수 있다. 중복 규모, 즉 fan-out 규모가 클수록 저장 부담과 자원 소모가 심해져 시스템 capacity상 감당하지 못하는 지점이 생길 수 밖에 없다. 앞서 100만 팔로워를 가진 셀럽이 새 게시글을 작성한 예시를 생각해보자.
fanout-on-write 전략을 사용할 경우, 셀럽이 게시글을 업로드하는 하나의 행동이 100만 건의 알림 이벤트 생성 작업으로 이어진다. 이러한 상황에서는 단순한 업로드 작업이 시스템 전체에 큰 부하를 유발하여 처리 지연, 저장공간 부담 등 다양한 문제를 연쇄적으로 일으킬 수 있다. 따라서 알림의 대상 범위가 넓어질수록 fan-out 전략은 자연스럽게 write에서 read 중심으로 이동하게 된다.
팔로워 수가 많은 사용자의 활동이 대규모 fan-out을 유발하며 시스템에 부담을 주는 상황을 Celebrity Problem이라고 한다.
이러한 문제를 해결하기 위해 모든 사용자에게 동일한 방식으로 fan-out을 적용하기보다는 유저의 규모에 따라 다른 전략을 사용하는 방식이 등장하게 된다.
해결 방안
기본적으로 일반 유저의 행동에 의한 알림은 fanout-on-write으로 구현된다. 많은 팔로워를 가진 유저의 행동은 서비스의 가용 자원을 넘어서는 fan-out을 유발하므로 fanout-on-read에 기반하여 구현될 수 밖에 없다.
Fanout-on-read + Lazy fanout
팔로워가 많은 유저의 행동은 알림 이벤트 생성까지 진행하지 않고 그 행동 이벤트 자체만 저장해둔다. 이 상태에서 팔로워가 피드 조회 같은 방식으로 팔로우한 셀럽의 행동 이벤트 조회를 요청하는 시점에 팔로우 목록을 돌며 저장된 행동 이벤트를 기반으로 알림 이벤트를 생성한다. 이는 유저가 조회하는 시점에 필요한 데이터를 계산하는 fanout-on-read 방식이다.
또한 유저가 최초 조회하는 시점에 알림 이벤트를 생성하고 알림을 저장하는 lazy fanout 방식이 사용된다. 이 구조는 유저가 처음 조회할 때까지 알림 도메인 로직을 실행하지 않는다. 따라서 일부 활성 유저에 대해서만 fan-out이 수행되고 스토리지를 효율적으로 사용할 수 있게 된다. 나아가 이후 조회부터는 행동 이벤트 수집없이 이미 생성된 알림 데이터만을 조회함으로써 read 병목을 최소한으로 하여 균형을 맞춘다.
인스타그램에서 친구의 라이브 방송 알림은 라이브 시작과 거의 동시에 오는데 팔로우한 셀럽의 라이브 방송은 시작하고도 한참 알림이 뜨지 않은 경험이 자주 있다. 대부분 알림보다는 피드에 떠서 들어간 경우가 많은 것도 이 때문일 것이라 추측한다. 추가로 알림을 못받은 채 피드로 라이브 방송을 들어가고 나면, 그제서야 방송 시작 알림이 오던 경험도 이 Fanout-on-read + lazy fanout 방식으로 설명이 된다.
전체 공지
공지와 같은 전체 알림의 경우에도 Celebrity Problem 상황과 본질적으로 동일하다. 유저 전체를 수신 대상자로 하는 알림을 미리 생성하는 fanout-on-write 방식은 비효율적이므로 하나의 공지 이벤트를 저장해두고 유저가 접속 또는 조회하는 시점에 보여주는 read 기반 전략이 주로 사용된다. 다만 실제 시스템에서는 읽음 상태 관리나 푸시 알림 처리와 같이 일부 write 작업이 함께 수행되는 hybrid 형태로 구현되는 경우가 많다.
결국 fan-out 시점에 따른 두 방식의 차이는 데이터를 언제 생성할 것인가의 문제로 귀결된다. 이 막대한 비용을 write 시점에 지불할 것인지 read 시점에 지불할 것인지에 대한 선택이다. 물론 이 트레이드오프는 비용 뿐만 아니라 서비스 성격, 트리거 도메인, 이벤트 특징을 고려하여 결정하게 된다.
'scalability' 카테고리의 다른 글
| 알림 아키텍처 (1) (0) | 2026.03.27 |
|---|