다중 인스턴스 환경에서 실시간 알림 전송하기

k_bell·2025년 8월 14일

트러블슈팅

목록 보기
6/7

이번 글에서는 무선 데이터 거래 플랫폼 ‘다챠’를 개발하며 실시간 알림 기능을 구현했던 경험을 공유하고자 합니다. 사용자가 상품을 구매하거나 입찰하는 등 주요 행동을 했을 때, 안정적으로 알림을 보내주는 것은 서비스의 필수적인 기능이었습니다.

처음에는 단일 서버에서 ConcurrentHashMap과 SSE(Server-Sent Events)를 이용한 단순한 방식으로 시작했습니다. 하지만 서비스 확장을 위해 로드밸런서를 도입하고 다중 인스턴스로 전환하면서부터 예상치 못한 문제들이 발생하기 시작했습니다.

이 글에서는 제가 직접 겪은 트러블과, 이를 Redis Pub/SubKafka를 활용해 해결하며 안정적인 실시간 알림 시스템을 구축한 경험을 소개해드리고자 합니다.


1. 분산 환경에서의 상태 불일치

가장 처음 설계했던 알림 시스템의 구조는 매우 단순하고 직관적이었습니다.

  • 사용자 ID(Long)를 Key로, 클라이언트와 연결된 SseEmitter 객체를 Value로 갖는 ConcurrentHashMap을 사용했습니다.
  • 알림이 발생하면, SseEmitterService가 Map에서 해당 사용자의 Emitter를 찾아 직접 메시지를 전송했습니다.

하지만 로드밸런서를 통해 다중 인스턴스 구조로 확장되자 많은 문제가 발생했습니다.

  • 상황: 사용자가 인스턴스 A와 SSE 연결을 맺으면, Emitter 객체는 인스턴스 A의 메모리에 저장됩니다.
  • 문제: 이후 알림을 보내라는 요청이 로드밸런서에 의해 인스턴스 B로 전달되면, 인스턴스 B는 자신의 메모리에 해당 사용자의 Emitter 정보가 없으므로 알림을 보낼 수 없습니다.
  • 결과: 알림은 유실되고, 사용자는 알림을 받기도 하고 못 받기도 하는 예측 불가능한 상황에 놓입니다.

결국 각 서버 인스턴스가 자신의 메모리에만 연결 상태를 가지고 있다는 점이 근본적인 문제였습니다.


2-1. Redis Pub/Sub으로 인스턴스 간 신호 전송

SseEmitter 객체 자체는 직렬화가 불가능해 여러 인스턴스가 공유할 수 없었습니다. 그래서 저희는 접근 방식을 바꿔, "이 사용자에게 알림을 보내라"는 신호를 모든 인스턴스에 전파하기로 했습니다. 이 역할을 수행하기 위해 Redis의 Pub/Sub 기능을 도입했습니다.

Redis Pub/Sub은 발행/구독(Publish/Subscribe) 모델을 따르는 메시징 시스템입니다.

  • 발행자(Publisher): 메시지를 특정 '채널'에 발행(전송)합니다.
  • 구독자(Subscriber): '채널'을 구독하고 있다가 메시지가 올라오면 즉시 수신합니다.

이를 통해 어떤 인스턴스든 알림이 필요할 때 Redis 채널에 메시지를 발행하면, 해당 채널을 구독하는 모든 인스턴스가 메시지를 받아 처리할 수 있게 됩니다.

이 구조에서 각 인스턴스는 Redis로부터 메시지를 받으면, 자신의 메모리에 저장된 ConcurrentHashMap을 확인합니다. 그리고 실제 Emitter 연결을 가지고 있는 단 하나의 인스턴스만이 클라이언트에게 알림을 최종 전송합니다.

아래 코드를 통해 동작 흐름을 이해할 수 있습니다. Redis 채널에 메시지가 도착하면 onMessage 메소드가 실행됩니다.

// RedisNotificationSubscriber.java

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisNotificationSubscriber implements MessageListener {
    private final ObjectMapper objectMapper;
    private final SseEmitterService sseEmitterService;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            // Redis로부터 받은 메시지를 DTO로 변환
            String json = new String(message.getBody());
            NotificationDto dto = objectMapper.readValue(json, NotificationDto.class);
            log.info("[RedisSubscriber] 수신된 알림: {}", dto);

            // 자신의 SseEmitterService에게 알림 전송을 위임
            sseEmitterService.send(dto.getUserId(), dto);
            log.info("[RedisSubscriber] SSE 전송 완료: userId={}", dto.getUserId());

        } catch (Exception e) {
            log.error("[RedisSubscriber] 메시지 처리 실패", e);
        }
    }
}

여기서 호출하는 sseEmitterService.send() 메소드는 오직 자신의 메모리에 있는 emitters 맵만 확인합니다.

// SseEmitterService.java

public void send(Long userId, NotificationDto notificationDto) {
    // 자신의 메모리에서만 Emitter를 찾는다.
    SseEmitter emitter = emitters.get(userId);
    if (emitter != null) {
        // Emitter가 존재하면 클라이언트로 전송
        sendToClient(emitter, userId, "notification", notificationDto);
    } else {
        // Emitter가 없으면 아무것도 하지 않고 무시
        log.warn("[send] 알림을 보낼 Emitter를 찾을 수 없습니다. userId={}", userId);
    }
}

이로써 온라인 상태인 사용자에 대한 알림 유실 문제를 해결할 수 있었습니다.


2-2 Kafka로 데이터 영속성 보장하기

Redis Pub/Sub 도입으로 실시간 알림은 안정화되었지만, 새로운 문제가 드러났습니다. Redis Pub/Sub은 구독자가 없으면 메시지를 그냥 버리는 'Fire-and-Forget' 방식입니다. 만약 사용자가 오프라인 상태라면 활성화된 SseEmitter가 없으므로 Redis로 보낸 알림 신호는 그대로 증발합니다. 사용자가 나중에 접속해도 놓친 알림을 확인할 방법이 없었습니다.

알림 데이터의 영속성(Persistence)을 보장하기 위해, 저희는 Kafka를 도입했습니다. Kafka는 대용량 이벤트를 안정적으로 기록하고 처리할 수 있는 분산 스트리밍 플랫폼입니다.

따라서 전체적인 알림 처리 흐름을 다음과 같이 완성할 수 있었습니다.

  1. (발행) 서비스 로직에서 알림이 발생하면, 이벤트 내용을 Kafka 토픽으로 보냅니다.
  2. (소비 및 저장) Kafka 컨슈머 그룹(notification-group)에 속한 단 하나의 NotificationService 인스턴스가 메시지를 받아, 알림 내역을 데이터베이스에 저장합니다. 이로써 데이터 영속성이 확보됩니다.
  3. (실시간 신호 전송) DB 저장이 완료된 후, 해당 인스턴스는 실시간 전송을 위해 Redis 채널(alarm-channel)에 알림 신호를 발행합니다.
  4. (클라이언트 전송) 이후 과정은 1단계와 동일합니다. Redis 신호를 받은 인스턴스 중 실제 SSE 연결을 가진 인스턴스가 클라이언트에게 알림을 보냅니다.
// NotificationService.java

@Transactional
@KafkaListener(topics = "notification", groupId = "notification-group")
public void consume(AlarmCreationDto creationDto) {
    log.info("[consume] 알림 생성 요청 수신: {}", creationDto.getContent());

    // 1. User, Status 등 정보 조회
    User user = findUserById(creationDto.getUserId());
    Status unReadStatus = statusManager.getStatus("ALARM", "UNREAD");

    // 2. 알림 객체를 생성하여 데이터베이스에 저장 (영속성 확보)
    Alarm alarm = Alarm.builder() /* ... */ .build();
    alarmRepository.save(alarm);
    log.info("[consume] 알림 저장 완료: 알림 ID={}, 사용자 ID={}", alarm.getAlarmId(), user.getUserId());

    // 3. 실시간 전송을 위한 DTO 생성
    NotificationDto notificationDto = NotificationDto.fromEntity(alarm, findTransactionFeedById(creationDto.getTransactionFeedId()));

    try {
        // 4. Redis 채널에 실시간 전송 신호를 발행
        String json = objectMapper.writeValueAsString(notificationDto);
        redisTemplate.convertAndSend("alarm-channel", json);
        log.info("[consume] Redis Pub 전송 완료: userId={}, alarmId={}", creationDto.getUserId(), alarm.getAlarmId());
    } catch (Exception e) {
        log.error("[consume] Redis Pub 전송 실패", e);
    }
}

Kafka가 데이터의 안정적인 저장을 책임지고, Redis는 저장된 데이터를 실시간으로 전달하는 역할을 분담하도록 시스템을 구축하였습니다.


4. 최종 아키텍처

  • Kafka: 알림 이벤트의 비동기 처리와 안정적인 DB 저장을 보장합니다.
  • Redis Pub/Sub: DB에 저장된 알림을 온라인 상태인 사용자에게 실시간으로 전달하기 위한 신호 역할을 합니다.
  • Application Instance: SSE 연결을 관리하고, Kafka로부터 온 데이터를 처리하며, Redis 신호를 받아 최종적으로 메시지를 전송합니다.

5. 그 외 잡다한 문제들: Nginx 설정

애플리케이션 아키텍처 개선 후에도 실제 배포 환경에서는 인프라단, 특히 리버스 프록시인 Nginx와 관련된 문제들이 남아있었습니다.

  • 타임아웃 문제: SSE는 서버와 클라이언트가 긴 시간 연결을 유지해야 합니다. 하지만 일정 시간 데이터 교환이 없으면 Nginx는 유휴(idle) 연결로 판단하고 연결을 끊어버렸습니다.

    • 해결: 서버에서 주기적으로 heartbeat 이벤트를 보내 연결이 살아있음을 Nginx에게 알려주었습니다. SseEmitterService에 스케줄러를 추가하여 30초마다 ping 코멘트를 보내도록 구현했습니다.
    // SseEmitterService.java
    
    public SseEmitter subscribe(Long userId) {
        // ... Emitter 생성 ...
        ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(
                () -> sendPing(emitter, userId),
                30, 30, TimeUnit.SECONDS
        );
        // ...
        return emitter;
    }
    
    private void sendPing(SseEmitter emitter, Long userId) {
        try {
            emitter.send(SseEmitter.event().comment("ping"));
            log.info("[sendPing] Heartbeat 전송 성공: userId={}", userId);
        } catch (Exception e) {
            log.error("[sendPing] Heartbeat 전송 실패, 연결을 종료합니다. userId={}, error={}", userId, e.getMessage());
            emitters.remove(userId);
        }
    }
  • 버퍼링 문제: 서버에서 이벤트를 보냈음에도 클라이언트에서 한참 뒤에 수신되는 지연 현상이 있었습니다. 원인은 Nginx의 proxy_buffering 설정 때문이었습니다. Nginx는 백엔드 서버의 응답을 일단 자체 버퍼에 모았다가 보내는 것이 기본 동작이라 실시간 통신을 방해했습니다.

    • 해결: Nginx 설정 파일에서 proxy_buffering off; 옵션을 추가하여 버퍼링을 비활성화했습니다.
    location /api/notifications/subscribe {
        proxy_pass http://backend-servers;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
    
        # 실시간 전송을 위한 핵심 설정
        proxy_buffering off;
        proxy_cache off;
    }

6. 결론

이번 경험을 통해 분산 환경에서의 시스템 설계에 대해 많은 것을 배울 수 있었습니다.

  • 단순한 메모리 기반 상태 관리는 분산 환경에서 부적합하며, 상태를 외부에서 관리하는 것이 중요합니다.
  • Redis Pub/Sub은 여러 인스턴스 간의 빠른 실시간 신호 공유에 효과적입니다.
  • Kafka는 이벤트의 유실을 막고 데이터 영속성을 보장하여 시스템 전체의 신뢰도를 높여줍니다.
  • 각 기술의 특성(Kafka의 신뢰성, Redis의 속도)을 이해하고 역할에 맞게 조합함으로써 확장 가능하고 안정적인 아키텍처를 구축할 수 있습니다.

문제를 정의하고, 하나씩 해결하며 시스템을 발전시키는 과정을 통해 많은 것을 배울 수 있었습니다. 특히 백엔드와 프론트엔드의 코드뿐만 아니라, 인프라 환경까지 함께 조율하는 과정을 거치면서 다양한 경험을 할 수 있었던 것 같습니다.

0개의 댓글