실시간 알림 시스템 구현을 위한 RabbitMQ 활용기

윤주원·2025년 9월 25일

서론

프로젝트를 진행하다 보면 사용자에게 실시간으로 알림을 보내야 하는 순간이 많다.
이번 프로젝트에서는 WebSocket을 이용한 실시간 알림 기능을 구현하려 했고, 처음엔 단순히 클라이언트와 서버 간의 연결만으로 충분할 거라 생각했다.
하지만 곧 이런 의문이 생겼다

“알림을 보내야 할 이벤트는 백엔드 여러 곳에서 발생하는데, 이걸 어떻게 WebSocket 서버에 안정적으로 전달하지?”

단순한 함수 호출이나 API 요청으로는 구조가 너무 복잡해지고, 서비스 간 결합도가 높아져 유지보수가 어려워졌다.
게다가 알림을 놓치거나 중복해서 보내는 문제도 생길 수 있었다.
이런 고민 끝에 나는 RabbitMQ를 도입했다.
RabbitMQ는 각 서비스에서 발생한 이벤트를 메시지로 큐에 넣고, WebSocket 서버는 큐에서 메시지를 받아 사용자에게 전달하는 구조로 바꿨다.
이렇게 하니 서비스 간의 연결이 느슨해지고, 메시지 유실 없이 안정적으로 알림을 전달할 수 있었다.

이 글에서는 내가 왜 RabbitMQ를 선택했는지, 어떤 문제를 해결하고자 했는지, 그리고 RabbitMQ가 어떻게 작동하는지를 중심으로 풀어보려 한다.
실제 경험을 바탕으로, RabbitMQ를 처음 접하는 개발자들에게 도움이 되는 글이 되었으면 한다.

RabbitMQ 란?

RabbitMQ는 AMQP(Advanced Message Queuing Protocol)라는 표준 프로토콜을 기반으로 동작하는 메시지 큐 시스템이다. 쉽게 말해, 여러 서비스 사이에서 메시지를 안전하게 전달하고, 처리 순서를 조절하며, 시스템 간 결합도를 낮춰주는 중간 관리자 역할을 한다.

왜 rabbitMQ가 필요한가?

내가 구현하려던 알림 기능은 여러 서비스에서 발생하는 이벤트를 하나의 흐름으로 통합해야 했다. RabbitMQ는 이 요구에 딱 맞는 구조를 제공했다.
각 서비스는 RabbitMQ에 메시지를 던지기만 하면 되고, WebSocket 서버는 그 메시지를 받아 사용자에게 전달하면 되니, 서비스 간 결합도는 낮추면서도 실시간 알림은 유지할 수 있었다.

RabbitMQ의 구조

RabbitMQ는 다음과 같은 흐름으로 메시지를 처리한다.

Producer → Exchange → Queue → Consumer
  • Producer: 메시지를 생성해서 RabbitMQ에 보냄 (예: 스터디 가입 요청 메시지 발생)
  • Exchange: 메시지를 라우팅 규칙에 따라 어떤 큐로 보낼지 결정하는 라우터 역할
  • Queue: 메시지를 저장하는 공간 (여러 개 가능)
  • Consumer: 큐에서 메시지를 꺼내서 처리하는 주체 (예: WebSocket 서버)

이 구조 덕분에, 나는 스터디 가입이나 승인 같은 이벤트가 발생했을 때, 해당 메시지를 RabbitMQ에 보내고, WebSocket 서버가 그 메시지를 받아 사용자에게 실시간 알림을 전달하는 구조를 만들 수 있었다.

RabbitMQ의 특징

  • 비동기 처리: 메시지를 보내는 쪽과 받는 쪽이 동시에 동작하지 않아도 됨
  • 내구성(Durability): 메시지를 디스크에 저장해 서버가 재시작돼도 메시지가 유지됨
  • 확장성: 여러 소비자가 동시에 메시지를 처리할 수 있어 병렬 처리에 유리함
  • 라우팅 유연성: Direct, Fanout, Topic 등 다양한 방식으로 메시지를 분배 가능

내 프로젝트에서의 작동 방식

1️⃣ 메시지 발행

rabbitTemplate.convertAndSend(exchangeName, routingKey, message)
  • Exchage에 메시지 전달
  • 모든 메시지는 고정된 "notification.key"로 발행됨
  • Exchange는 Direct Exchange라서 이 키를 보고 바인딩된 큐(notification.queue)로 메시지 전달

2️⃣ 큐 바인딩

@Bean
public Binding binding(Queue queue, DirectExchange exchange) {
    return BindingBuilder.bind(queue).to(exchange).with("notification.key");
}
  • 고정된 키 "notification.key"가 notification.queue에 바인딩됨
  • 메시지는 항상 notification.queue에 쌓임

3️⃣ 메시지 수신 및 분기 처리

@RabbitListener(queues = "notification.queue")
public void receive(NotificationMessage message) {
    // DB 저장
    notificationRepository.save(message);

    // 사용자별 WebSocket 전송
    messagingTemplate.convertAndSendToUser(
        message.getReceiverId().toString(),
        "/topic/notifications",
        message.getContent()
    );
}

• Consumer는 모든 메시지를 수신
• 메시지 내부의 receiverId를 기준으로 사용자별로 WebSocket 전송


4️⃣ 클라이언트 수신

  • 클라이언트는 STOMP로 /user/{username}/topic/notifications 경로를 구독
  • 서버에서 convertAndSendToUser()로 해당 사용자에게만 메시지 전송
  • UI에 알림 표시

Exchange와 Routing Key

1️⃣ Exchange란?

Exchange는 메시지를 받아서, 라우팅 키를 기준으로 어떤 큐로 보낼지 결정하는 분배것을 말함

  • Producer가 발행한 메시지를 받아서 어떤 큐로 보낼지 결정

  • 메시지를 직접 저장하지는 않음

Exchange 종류:

  • Direct: Routing Key와 큐 바인딩 키가 정확히 일치하는 큐로 메시지 전달

  • Fanout: Routing Key 무시, 모든 큐로 메시지 브로드캐스트

  • Topic: Routing Key 패턴 기반 매칭, 복잡한 1:N, N:M 라우팅 가능

  • Headers: 메시지 헤더 기준으로 라우팅

내 프로젝트에서는 모든 알림 메시지를 단일 큐로 보내고, 메시지 종류를 구분하기 위해 Direct Exchange 방식을 사용했다.


2️⃣ Routing Key란?

Routing Key는 메시지를 어떤 큐로 보낼지를 결정하는 문자열 키

  • 어떤 큐로 메시지를 전달할지 결정하는 “주소” 역할

  • Direct Exchange에서는 Routing Key와 바인딩 키가 정확히 일치해야 큐로 전달


왜 RabbitMQ를 선택했나? (프로젝트 적용 이유)

프로젝트에서는 스터디 신청, 승인/거부 알림처럼 사용자에게 실시간으로 전달되어야 하는 이벤트가 많았다.
기존 WebSocket만으로는 문제점이 있었다:

  • 이벤트 발생 지점이 백엔드 여러 곳에 흩어져 있음 → 서비스 간 결합도 증가

  • 단순 함수 호출로 메시지를 보내면 유지보수가 어려움

  • 알림 유실 또는 중복 전송 가능성 존재

RabbitMQ를 도입하면서 다음 구조를 만들었다:

  • 각 서비스에서 발생한 이벤트를 메시지로 변환 후 큐에 발행

  • WebSocket 서버는 큐에서 메시지를 수신(Consumer) → DB 저장 + 사용자별 WebSocket 전송

  • Exchange + Routing Key를 활용해 메시지를 사용자별/이벤트별로 분류

이 구조 덕분에 서비스 간 연결이 느슨해지고, 알림 메시지 유실 없이 안정적으로 전달 가능했다.


안정적인 메시지 전달 보장

위에서 설명했듯이 rabbitmq에는 안정적인 메시지 전달을 보장하는 장점을 갖고있다.
RabbitMQ는 기본적으로 메시지를 큐에 안전하게 저장하고,
Consumer가 ack를 보내야만 메시지를 제거한다.
Ack 전에는 메시지가 큐에 남아 있어, Consumer 장애 시 다른 Consumer로 재전송 가능하다
이 덕분에 알림이 유실되는 상황을 방지할 수 있다.

Kafka와같은 다른 메시지 브로커와의 간단한 비교

특징RabbitMQKafka
메시지 모델Queue 기반, AMQPTopic 기반, 로그 구조
순서 보장단일 큐 내 순서 보장Partition 단위 순서 보장
안정성Ack 기반 → 처리 실패 시 재전송Offset Commit 기반 → Commit 전략 따라 유실 가능
실시간 처리Consumer 즉시 처리 가능Consumer polling 필요, 오버헤드 존재
메시지 분류Exchange + Routing Key로 세밀하게 분류 가능Topic 단위 분류, 큐 단순 분류 어렵다
  • 우리 프로젝트는 실시간 알림 + 단일 큐 구조 → RabbitMQ가 직관적이고 구현이 간단

  • Kafka는 대용량 스트리밍 처리에는 강점이 있지만, 작은 알림 시스템에는 오버헤드가 발생

RabbitMQ를 쓰면서 느낀 장단점

장점

  • 안정적인 메시지 전달(Ack)

  • 사용자별 메시지 분류 용이 (Routing Key 기반)

  • WebSocket과 자연스러운 통합

  • 큐, Exchange, Consumer 확장 가능 → 서비스 확장성 확보

단점 / 주의할 점

  • 단일 큐 구조에서는 메시지가 모두 쌓이므로 큐 처리량이 많아지면 지연 가능

  • 메시지 내용이 많거나 처리 시간이 길면 Consumer 성능이 병목될 수 있음

  • Ack/Manual Ack 설정에 따라 안정성 vs 처리 속도 트레이드오프 존재

앞으로 개선할 부분 및 결론

앞으로는 현재 사용 중인 단일 큐와 단일 Routing Key 구조를 개선할 계획이다.
지금 구조는 구현이 간단하고 메시지 수가 많지 않을 때는 충분히 안정적이지만, 알림 이벤트가 다양해지고 사용자 수가 늘어나면 단일 큐에 모든 메시지가 몰리면서 처리 지연이나 Consumer 병목이 발생할 수 있다.
이를 해결하기 위해 이벤트 유형별로 큐를 분리하고, Routing Key도 세분화하는 다중 큐와 다중 키 구조를 도입할 예정이다.

profile
백엔드 개발자 윤주원 입니다.

0개의 댓글