Redis Pub/Sub 기반 SSE(Server-Sent Events) 실시간 알림 적용기

wwlee94·2023년 12월 31일
10

R&D

목록 보기
7/8
post-thumbnail

들어가며

이 글에서는 서비스 이용자에게 알림을 제공하기 위해 30초마다 주기적으로 서버에 API 호출을 하여 데이터를 받아오던 Polling 방식에서 SSE (Server-Sent Events) 를 활용하여 실시간 알림 기능을 구현하여 실무에 적용한 경험에 대해 공유하고자 합니다.

멀티 WAS 환경메시지 큐를 활용한 SSE 구현 레퍼런스가 거의 존재하지 않아서 SSE를 도입하려는 개발자분들께 도움이 되었으면 좋겠습니다.

SSE를 도입함으로서 저희 서비스에서 달성할 수 있었던 성과는 다음과 같습니다.

  1. 서비스 이용자에게 실시간으로 알림을 제공할 수 있다.
  2. 1주일간 발생했던 3,870,000(3.87M) 건 API 호출 수를 823,000(823K) 호출 수로 기존 대비 약 78.7% 개선하여 불필요한 API 호출 수를 줄일 수 있다.
  3. Redis Pub/Sub을 적용하여 멀티 WAS 환경에서도 문제 없이 요구사항을 달성 할 수 있다.
  4. Kafka 메시지 큐를 중간 계층에 두어 비즈니스 로직의 주체를 정하고 가용성을 높이고 결합도를 낮춰 유지보수에 용이하게 한다.

기존 설계의 문제점

기존 서비스에 적용된 설계는 일정 주기로 서버에 API를 호출하여 데이터를 조회하는 Short Polling 방식으로 구현되어 있었습니다.

polling

ref. Update campaigns and feature flag configurations instantaneously with Real-Time Streaming Architecture

Polling 방식으로 구현된 설계는 다음과 같은 문제점이 있습니다.

문제점

  • 실시간성 데이터를 제공받지 못한다.
  • 실제로 전달 받을 데이터가 없어도 불필요한 네트워크 커넥션을 새로 발생시켜 데이터를 확인 해야한다.

기존 서비스에 구현된 Polling 방식은 실시간성으로도 효율성으로도 비즈니스 요구사항을 달성 할 수 없는 구조였습니다.

이러한 단점들을 해소하고 실시간성을 달성하기 위해서 2가지 후보에 대하여 고민했습니다.
SSE(Server-Sent Events) 통신과 Web Socket 통신입니다.


SSE vs Web Socket

두가지 통신 기술은 모두 서버에서 클라이언트로 실시간 데이터를 전달할 수 있는 기술입니다.

SSE와 Socket의 가장 큰 차이점은 통신의 방향입니다.

  • SSE는 Server -> Client단방향 통신
  • Web Socket은 Server <-> Client 로서 양방향 통신

그 외에 두 가지를 비교하면 다음과 같습니다.

ref. 웹소켓과 SSE(Server-Sent-Event) 차이점 알아보고 사용해보기


SSE 통신으로 선택한 이유

두가지 통신 기술 중에서 SSE를 선택하게된 이유는 다음과 같습니다.

  1. SSE는 HTML5의 표준기술이고 전 세계 유저에 97%가 사용 가능 할 정도로 안정적이다.
    • Web Socket은 98.16%
  2. 연결이 끊어지면 EventSource API가 자동으로 재연결을 시도해준다.
  3. 알림은 효율적인 단방향 통신이 필요하였기 때문에 Server -> Client로 단방향 통신만 지원해도 된다.
  4. 모바일 환경도 함께 제공되고 있어서 HTTP 프로토콜은 호환성도 좋으며 개발 공수가 크지 않고, 배터리 소모량의 이점이 있다.

이렇게 서비스의 특성과 통신 기술의 특성을 비교하여 SSE가 보다 적합하다고 생각하여 채택하게 되었습니다.

Long polling이 후보에서 제외된 이유
SSE 통신은 한번의 연결을 통하여 서버에서 새로운 데이터가 있을 때만 이벤트 스트림을 통해 데이터를 전송하여, 일반적으로 실시간 업데이트나 알림에 적합하지만
Long polling 통신은 클라이언트 요청을 서버가 지속적으로 유지하고, 데이터가 업데이트되면 응답을 보내고 연결을 닫거나 새로운 요청을 기다리기 때문에 일반적으로는 더 많은 네트워크 부하가 발생할 수 있다.


SSE 적용하기

이 글에서는 SSE의 실제 구현에 대해서는 간략하게만 설명합니다. SSE를 도입하려면 어떤 부분들을 고려해야하는지 설명하려고 합니다.

SSE의 기본적인 동작 flow를 알아봅시다.

@Operation(summary = "SSE 구독")
@GetMapping(value = SSE_PREFIX + "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@AuthenticationPrincipal JobdaUser jobdaUser) {
    String userId = getUserIdOrThrow(jobdaUser);

    // SSE 초기화
    return sseEmitterService.init(userId);
}
/**
 * SseEmitter 초기화
 * <pre>
 * 1. SseEmitter 생성
 * 2. 콜백 함수 등록
 * 3. 503 에러 방지를 위한 더미 이벤트 전송
 * </pre>
 *
 * @author : lww1028
 */
public SseEmitter init(String userId) {
    String key = generateSseEmitterKey(userId);

    // SseEmitter 생성
    SseEmitter sseEmitter = sseEmitterRepository.save(key, new SseEmitter(DEFAULT_TIMEOUT));

    // 콜백 함수 등록
    sseEmitter.onCompletion(() -> sseEmitterRepository.deleteById(key));

    sseEmitter.onTimeout(() -> {
        sseEmitterRepository.deleteById(key);
        sseEmitter.complete();
    });

    // Broken Pipe 발생시
    sseEmitter.onError(throwable -> {
        log.error("SSE Emitter Error : ", throwable);
        sseEmitter.complete();
    });

    // 더미 이벤트 전송
    send(sseEmitter, key, "init", "EventStream Created. [userId=" + userId + "]");

    return sseEmitter;
}

위 코드는 아래 항목을 수행합니다.

  • 클라이언트에게 연결 정보를 구독할 수 있는 API를 제공
  • 로그인한 사용자의 ID로 SSE 연결정보 ID를 생성
  • 클라이언트와 서버 연결 정보 (SseEmitter)를 메모리에 저장하여 연결 세션 관리
    • 연결 세션 정보는 동시성 고려하여 ConcurrentHashMap 사용
  • 성공, 타임아웃, 실패 되었을때의 콜백 함수 등록
  • SseEmitter를 생성하고 만료 시간까지 데이터를 보내지 않는 경우, 재연결 요청시 503 Service Unavailable 에러가 발생할 수 있어 더미 이벤트 전송

이렇게 기본적인 SSE 동작 방식을 살펴봤는데요.

SSE를 적용하려는 분들은 아래 두가지 사항을 추가로 고려해야합니다.

첫째, 단일 WAS가 아닌 가용성을 위해 멀티 WAS 환경으로 구축된 환경이다.

  • SSE만으로는 구현이 불가

둘째, 알림 발송이 메인 프로젝트 뿐 아니라 여러 서브 프로젝트에서 동시 다발적으로 발송될 가능성이 있다.

  • 알림 발송 로직 소스가 여러 프로젝트에 중복
    • 구독 API를 제공하는 메인 서버 제외 서브 서버는 연결 정보를 모름
  • 대용량 처리를 위한 가용성을 고려해야함

고려 사항

첫째, 단일 WAS가 아닌 가용성을 위해 멀티 WAS 환경으로 구축된 환경이다.

Spring에서는 SSE 비동기 통신을 위한 SseEmitter 모듈을 제공하는데 '어떤 클라이언트에게 응답을 해야하는지'의 정보를 local-memory에 저장하고 있어야합니다.

local-memory에 저장하고 있다보니 멀티 WAS 환경에서는 앞단에 LB가 있을 수도 있고 요청마다 응답을 받는 WAS가 달라질 수 있기 때문에 정상 동작하기가 어렵습니다.

예시를 들어 설명하면 다음과 같습니다.

Velog 프로젝트에 A,B,C,D 서버가 있고 앞단에 LB(로드밸런서)가 있는 환경이라고 가정합니다.
1. 1번 사용자가 접속하고 LB가 A서버에 트래픽 할당한다.
2. B서버에서 알림 이벤트가 발생한다.
3. B서버에는 1번 사용자와의 연결 세션 정보가 없어서 발송 할 수 없다.

위와 같은 이슈 때문에 멀티 WAS 환경인 경우 Redis의 Pub/Sub 기능이 반드시 필요합니다.

pub/sub을 활용하면 B에서 발생한 이벤트를 모든 서버에 알려서 1번 사용자와 엮인 서버를 찾아서 발송해주도록 구현이 가능합니다.

Redis Pub/Sub을 활용

pub/sub은 구독, 발행 모델로 Kafka의 produce, consumer 의 개념과 비슷한 개념입니다.
Kafka의 경우 consumer 그룹 중 1개만 소비하는 구조로 고가용성 및 영속성을 보장하는 메시지큐의 역할이고
pub/sub의 경우 구독한 모든 채널로 데이터를 Broadcast로 전달하는 역할입니다.

pub/sub을 활용하게 되면 A 서버에서 응답을 하는 경우에 A 뿐 아니라 B 서버에게도 데이터를 전달할 수 있게 됩니다.

A서버 : 921 포트
B서버 : 9211 포트

  1. A 서버에 SSE 연결 정보 구독
    A 서버에 lww1028 연결 정보 local-memory 저장
    pub/sub-1
  2. B 서버에서 lww1028 계정에 메시지 발행
    pub/sub-2

A,B 각각의 서버에서는 local-memory에 SseEmitter의 정보가 있는지 확인 한 후 응답하게 됩니다.

  1. A 서버에서 B 서버에서 발송한 메시지가 수신되는지 확인
    pub/sub-3

둘째, 알림 발송이 메인 프로젝트 뿐 아니라 여러 서브 프로젝트에서 동시 다발적으로 발송될 가능성이 있다.

알림 이벤트가 발생하는 곳이 1가지 프로젝트에서만 발생할거라고 확신 할 수 있을까요?

Velog 프로젝트와 Velog-Scheduler 프로젝트 2개가 있다고 가정해봅시다.

  • Velog 프로젝트는 연결 정보를 관리하고 알림 발송 로직을 관리
  • Velog-Scheduler 프로젝트는 스케줄링 로직을 처리하는 프로젝트이고 알림 발송 로직을 관리하며 글이 올라오고 4시간뒤에 알림을 발송해주는 역할

이런 경우 Velog, Velog-Scheduler 양쪽에 알림을 발송하는 로직 포함 Redis Pub/Sub 로직 등이 분산되어 양쪽에서 관리됩니다.

추후 프로젝트가 하나 더 나와서 알림을 발송하는 로직이 추가된다고하면 어떻게 될까요?
또는 발송 로직이 수정된다고 하면 어떻게 될까요?

관리 포인트가 여러개가 되어 관리하기 어려워질 것입니다.

또한, 알림 발송 트래픽이 몰리는 경우, 모든 서버에 이벤트를 발송하게 될텐데, 이때 트래픽을 감당하지 못해 서버가 죽을 수도 있습니다.

이러한 니즈는 Kafka 메시지 큐를 활용하여 해결 할 수 있습니다.

발송 로직과 Redis pub/sub로직은 Velog 프로젝트 한 곳에서만 관리하고
Velog-Scheduler에서는 Redis의 구현과 발송 로직 구현을 알 필요 없이 Kafka 발행을 통해서만 알림을 발송할 수 있도록 하면 됩니다.


정리

아키텍처

개선 구조 간략화

기존에 실시간성 알림을 제공하지 못하고 불필요한 API 호출이 빈번하게 발생하던 Polling 방식의 구조를 SSE를 사용하여 개선해봤습니다.

기술적으로 Redis Pub/Sub을 활용하여 멀티 WAS 환경에서도 SSE가 동작할 수 있도록 구현하였고 Kafka 메시지 큐를 활용하여 프로젝트간 결합도를 낮추고 서비스 가용성을 증가 시켰습니다.

개선 사항으로는 SSE를 도입하여 실시간성 알림 구현이 가능하게 되었고 알림 뿐 아니라 다양한 실시간성 비즈니스 요구사항을 달성할 수 있게 되었습니다.

또, API 호출 수를 주간 3,870,000건에서 823,000건으로 대폭 개선하였고 서버가 클라이언트에게 필요할때 데이터를 전달할 수 있어 불필요한 네트워크 트래픽이 감소되었고 서버 부하를 줄일 수 있었습니다.

API 호출 수

변경 전
기간 : 2023-09-20 17:00 ~ 2023-09-27 17:00
총 호출 수 : 3,870,000회
초당 호출 수 : 6.4회

변경 후
기간 : 2023-10-17 17:00 ~ 2023-10-24 17:00
총 호출 수 : 823,000회
초당 호출 수 : 1.4회

이로써 실시간성과 시스템 관리를 효과적으로 개선하였습니다.

참고 사항

  1. EventSource에 헤더 넣는 방법 (event-source-polyfill)
    [Devlog] SSE로직 구현 중, EventSource에 headers담기

  2. Eventsource API : EXCEPTION: No activity within 300000 milliseconds. Reconnecting 이슈
    polyfill 사용하는 경우 default timeout이 45초기 때문에 백엔드에서 그보다 크게 timeout을 설정한 경우 45초마다 재연결을 시도할 가능성이 있어서 hearteatTimeout 옵션 조정이 필요합니다.

  3. SseEmitter 사용시 발생하는 Exception 대응
    Spring Boot, SSE(Server-Sent Events)로 단방향 스트리밍 통신 구현하기

  4. Spring을 사용한다면, JPA 사용시 osiv는 반드시 off 처리
    커넥션을 계속 점유하여 커넥션풀이 고갈되는 이슈 발생하기 때문에 open-in-view를 반드시 off 해야합니다.

  5. LB의 Idle Connection Timeout 확인
    Idle Connection Timeout이 SSE 연결 시간보다 큰 지 확인해야합니다.

레퍼런스

웹소켓과 SSE(Server-Sent-Event) 차이점 알아보고 사용해보기
Update campaigns and feature flag configurations instantaneously with Real-Time Streaming Architecture
Spring Boot, SSE(Server-Sent Events)로 단방향 스트리밍 통신 구현하기

마무리

적용한 항목 외에 추후 재연결 시도시 메시지가 누락될 가능성이 있는 부분을 개선할 예정입니다.

다양한 관점에서 여러 서비스를 결합하여 기능을 도입해보니 좋은 경험을 쌓아 성장할 수 있는 계기가 된 것 같고
R&D에서 끝나는게 아니라 이렇게 포스팅으로 글을 남기게 되었는데 오랜만에 글을 쓰다보니 익숙하지 않았지만 앞으로 이력 남기는 걸 습관화해서 2024년을 보내고 싶습니다.

봐주셔서 감사합니다! 🙇🏻‍♂️

profile
개발 블로그 📝

3개의 댓글

comment-user-thumbnail
2024년 1월 18일

SSE 잘봤습니다

1개의 답글
comment-user-thumbnail
2024년 9월 13일

redis pub/sub 구조에서 이를 kafka의 consumer를 여러개 두어서 해결하는 방식으로는 안될까요? 기존 사용하던 kafka가 있다면 이를 사용하면 안되는지 궁금합니다. redis를 추가적으로 구성하신 이유가 무엇일까요?

답글 달기