코드 저장소.

일정알림기능2- 실시간 알림에 카프카를 사용이유 본문

포폴/일정관리 프로젝트 vol.01

일정알림기능2- 실시간 알림에 카프카를 사용이유

slown 2025. 5. 17. 14:28

목차

1.기존의 코드의 문제점

2.왜 Kafka를 사용을 했는가?

3.적용

4.후기

 

1.기존 코드의 문제점

현재 일정관리프로젝트에서는 스프링 이벤트 드라이븐을 사용해서 단일 인메모리 이벤트 처리로 후처리를 하고 있지만 해당 방식에는 다음과 같은 문제가 있습니다. 

  • 비동기 처리라 해도 같은 서버 내에서만 작동
    → 이벤트를 발행한 서버 내부에서만 처리되기 때문에 다중 인스턴스 환경에서는 서버 간 이벤트 공유가 어렵습니다.
  • 서버 장애 시 이벤트 유실 가능성
    → 스프링 이벤트는 영속성이 없어 처리 중 서버 장애가 발생하면 복구가 어렵습니다.
  • 후처리 로직이 많아질수록 하나의 서비스에 부하 집중
    → 알림, 추천, 로그 처리 로직이 계속 누적되면서 애플리케이션 책임이 커집니다.
  • 서비스 간 확장 어려움
    → 실제로 서비스 서버를 분산 구조로 확장하면서 이벤트 처리 구조를 별도로 분리하기 어려운 제약이 있었습니다.

결론적으로, 비동기 이벤트 처리의 영속성, 확장성, 결합도 측면에서 한계를 드러내기 시작했습니다.

2.왜 Kafka를 사용을 했는가?

알림 시스템의 비동기 처리를 위해 4가지 대안을 검토했습니다. 각 기술이 '장애 상황에서의 복구력''메시지 전달 모델'에 따라 시스템에 어떤 영향을 주는지 분석했습니다.

1) Redis Pub/Sub (방송 방식)

  • 메커니즘: 메시지 보관소(Queue) 없이 실시간으로 메시지를 밀어내는(Push) 채널 방식.
  • Redis Pub/Sub는 모든 구독자에게 동일한 메시지를 전달합니다. 따라서 알림 서버 인스턴스가 여러 대일 경우 중복 처리 가능성이 있으며, 발행 시점에 구독자가 없으면 메시지를 재처리할 수 없습니다.
  • 판단: 알림 시스템은 단순 전달보다 장애 발생 시 복구 가능성이 더 중요했습니다. Redis Pub/Sub은 빠른 전달에는 적합하지만 메시지 보존이 없어 분산 환경의 알림 처리 구조에는 적합하지 않았습니다.

2) RabbitMQ (Push 기반 메시지 브로커)

  • 메커니즘: 브로커가 전달 상태를 관리하며, 소비 완료(ACK) 시 메시지를 삭제를 하는 구조.
  • 운영적 한계: RabbitMQ는 안정적인 전달에는 강점이 있지만 Kafka처럼 장기간 로그를 보관하며 과거 메시지를 반복 재처리하는 구조에는 상대적으로 제한이 있습니다.
  • 판단: 대량 알림 처리 환경에서는 전달 성공 여부뿐 아니라 운영 중 발생할 수 있는 오류에 대해 다시 처리할 수 있는 구조가 필요했습니다. 이 때문에 단순 전달보다 로그 보존에 강한 구조가 필요했습니다.

3)  Apache Kafka (Pull 기반 분산 로그) - [최종 채택]

  • 메커니즘: 메시지를 디스크 기반 로그에 저장하고, Consumer가 자신의 Offset 기준으로 필요한 시점부터 읽어가는 방식입니다.
  • 결정적 채택 근거
    • 오프셋(Offset) 기반 복구: Consumer 서버가 일시적으로 중단되더라도 마지막으로 처리한 Offset 이후부터 다시 읽을 수 있습니다. 이 덕분에 장애 발생 시 메시지가 바로 유실되지 않고 복구 이후 이어서 처리할 수 있는 구조를 만들 수 있었습니다.
    • Consumer Group의 정합성: 같은 Consumer Group 내에서는 하나의 메시지를 하나의 Consumer만 처리합니다.
      따라서 서비스 서버가 여러 대로 확장되더라도 동일 알림이 중복 발송되는 문제를 줄일 수 있었습니다.
    • Outbox 지연 스파이크 흡수: 100VU 부하 테스트 과정에서 Outbox → Kafka Publish 구간에 일시적인 지연 스파이크가 발생했지만, Kafka가 메시지를 순차적으로 적재하며 버퍼 역할을 수행해 HTTP API 에러 없이 안정적으로 처리할 수 있었습니다. (해당 근거는 Kafka Exactly-Once 보장하기: Outbox + EventId 기반 멱등 처리 글을 참고하시면 됩니다.)

3.적용

기능 모듈 위치 이유
Kafka Consumer (알림 수신) notification.connector.in-connector Kafka에서 들어오는 메시지를 수신 → inbound 역할
알림 저장/발송 비즈니스 로직 notification.core.service 핵심 도메인 로직은 core에 위치해야 함
Notification Entity notification.core.model 알림 도메인 객체는 core의 책임
Kafka DTO notification.connector.api-model Kafka 외부 메시지에 대한 직렬화 모델이므로 connec

 

그럼 위와 같이 적용을 하게 되면 아래의 그림처럼이 됩니다.

 

코드 흐름 요약

알림 발행 (Producer)

NotificationEvents notificationEvent = NotificationEvents.builder()
    .receiverId(event.getMemberId())
    .message("회원가입 완료 알림")
    .notificationType("SIGN_UP")
    .createdTime(LocalDateTime.now())
    .build();

notificationEventProducer.sendNotification(notificationEvent);

 

알림 수신 (Consumer)

@KafkaListener(topics = "notification-events", groupId = "notification-group")
public void consume(NotificationEvents event) {
    simpMessagingTemplate.convertAndSend(
        "/topic/notifications/" + event.getReceiverId(),
        objectMapper.writeValueAsString(event));
}

전체 구성 흐름

구조는 아래와 같이 변경되었습니다.

회원가입 이벤트 발생
→ NotificationEvent 생성
→ Kafka Producer 발행
→ notification-events Topic 저장
→ Consumer 수신
→ DB 저장
→ WebSocket 실시간 전송
 

4.후기

Kafka를 알림 시스템에 적용하면서 다음과 같은 변화를 확인할 수 있었습니다.

  • 알림 기능이 서비스 로직과 분리되어 결합도가 낮아졌습니다.
  • 새로운 알림 타입 추가 시 기존 서비스 수정 범위가 줄었습니다.
  • 일시적인 트래픽 증가 상황에서도 메시지를 버퍼링해 안정적으로 처리할 수 있었습니다.
  • WebSocket, 이메일, DB 저장 등 후처리 확장이 쉬워졌습니다.

특히 이후 서비스 서버를 다중 인스턴스로 확장하는 과정에서도 기존 알림 구조를 크게 수정하지 않고 유지할 수 있었던 점이 가장 의미 있었습니다. 물론 Kafka 설정 자체는 단순하지 않았고, 이후에는 DLQ와 Outbox 패턴을 추가해 장애 복구와 메시지 정합성을 더 보완했습니다.

 

한계 및 개선점

 

현재 구성은 개인 프로젝트 환경으로 인해
단일 Kafka Broker로 운영하고 있습니다.

이로 인해 다음과 같은 한계가 있습니다.

  • Broker 장애 시 메시징 전체가 중단될 수 있음
  • Replica 부재로 인한 데이터 유실 가능성
  • 리더 장애 시 자동 복구 한계

실제 운영 환경이라면 다음 구성이 필요합니다.

  • Broker 3대 이상 클러스터 구성
  • Replication Factor 3
  • ISR 최소 2 이상 유지
  • KRaft 기반 리더 선출 구성

개인 프로젝트에서는 운영 리소스와 비용을 고려해 단일 브로커로 구성했지만, 이 부분은 기능 구현이 아닌 명확한 트레이드오프로 판단한 영역이었습니다.