[Spring] SSE(Sever-Sent Events)

이재민·2024년 5월 28일
0

트러블슈팅&개선

목록 보기
3/5

개요

현재 서비스의 특정 화면에서 래플 이벤트의 당첨 확률의 정보를 표기해줘야하는 부분이 존재한다.
회원들의 응모가 이뤄질때마다 당첨 확률의 변동이 발생되어야 한다.
해당 요구사항을 해결하기 위한 방법은 여러가지가 있을 것이다.
API를 한번 더 호출하거나, polling 방식을 이용하는 등 다양한 방식이 존재할 것이다.

기존에는 변경된 확률을 즉각 반영해서 회원들에게 노출하지 않고 새로고침(API 호출)을 통해 변경된 확률이 노출되고 있었다.
이를 개선하기 위해 SSE를 활용하였고, SSE 활용을 앞서 현재 비즈니스를 간단한 프로토타입으로 개발하여 테스트를 진행하였다.

먼저, HTTP 기반으로 문제를 해결하기 위한 방법을 알아본 후 간단한 예제 코드로 확인하고자 한다. (단, API 재호출 방식은 제외)

HTTP 문제 해결법

Short Polling

클라이언트가 서버에 주기적으로 요청을 보내는 방식이다.
일정 시간마다 서버에 요청을 보내 데이터 변경이 발생했는지 확인하고 변경이 이뤄졌다면 응답을 받는다.

장점

  • 클라이언트 서버 모두 Short Polling 구현이 단순하다.
  • 실시간성이 중요하지 않는 요구사항에 적절할 수 있다.
    • QR 코드의 상태를 확인하기 위해 서버에 요청하는 방식 등

단점

  • 변경 유무와 관계없이 지속적으로 서버에 요청을 보내 서버 부담이 증가하게 된다.
    • 주기가 짧을 수록 부하는 커진다.
  • 네트워크나 HTTP 커넥션을 맺기 위한 비용이 발생하게된다. 불필요한 오버헤드가 발생한다.

Long Polling

요청을 보낸 후 서버에서 데이터 변경이 일어날 때까지 대기하는 방법이다.
실시간 메시지 전달이 중요하지만, 상태 변경이 빈번하지 않는 경우에 적합하다.
서버로 부터 응답을 받은 후 다시 연결 요청을 하기 때문에 상태가 빈번하게 발생하게 된다면 Short Polling과 같이 불필요한 오버헤드가 발생한다.

장점

  • 타임아웃 설정을 통해 Short Polling 보다는 HTTP 요청 수가 줄어든다.

단점

  • 요청 수를 줄이지만, 각 열린 요청이 여전히 서버에 연결을 유지하고 있다.
  • 클라이언트가 많을수록 서버 리소스에 부담이 증가한다.

Web Socket

클라이언트와 서버가 HTTP 기반으로 HandShaking 후 ws 프로토콜을 통해 상호간 응답을 주고 받는 방식.
단, websocket 프로토콜을 처리하기 위해 전이중 연결과 새로운 웹소켓 서버가 필요하다.

장점

  • 실시간 전송
  • 처음 한 번만 핸드쉐이크 과정만 수행하므로 통신 오버헤드가 낮다.

단점

  • 구현이 복잡하다.
  • HTTP/1.1 이하에서는 적합하지 않는다.
    • HTTP/1.1 에서는 데이터 전송이 일반적으로 순차적으로 이뤄지기 때문이다. 요청 후 응답 제공 방식.
    • 웹 페이지 상호 작용에는 충분하지만 실시간 통신에는 부족하다.
  • 로드 밸런싱, 메시지 크기 제한, 보안 문제 등 다양하게 고려해야 할 사항과 설계의 복잡성이 발생할 수 있다.

Server-Sent Events

클라이언트가 SSE 연결을 설정하면 서버는 연결을 열린 상태로 유지하여 지속적으로 업데이트 내역을 보냅니다.
서버가 정기적으로 클라이언트에 데이터를 푸시해야 하는 상황에 적합하며, 클라이언트는 서버에 정보를 다시 보낼 필요 없이 데이터만 수신한다.

장점

  • 구현이 비교적 단순하다.
  • 자동 재연결을 지원한다.
  • 리소스를 더 효율적으로 사용할 수 있다.
  • 새로운 커넥션을 설정하기 위한 오버헤드가 감소한다.

단점

  • SSE는 양방향 통신을 지원하지 않으므로 양방향 통신 요구사항에는 적절하지 않는다.
  • 서버에 많은 클라이언트가 연결될 경우 서버 리소스 부담이 증가할 수 있다.
  • 네트워크 환경이 불안정한 경우 연결 끊김이 발생하게 되고 이로 인해 재연결을 시도하여 오버헤드가 발생할 수 있다.

조금 더 자세히 SSE(Sever-Sent Events) 알아보기

SSE란, Server-Sent Events이며 서버가 클라이언트로 데이터를 실시간으로 보내는 단방향 통신 방식이다.

  • 서버는 클라이언트의 요청 없이도 데이터를 전송할 수 있다는 장점이 있다.
  • SSE를 통해 진행 상황을 보여주는 프로그레스 바, 실시간 뉴스 피드, 주식 거래 정보, 실시간 모니터링 서비스 등 다양한 상황에서 사용할 수 있다.
  • 클라이언트는 서버에 SSE 요청을 보내고(Header정보에 text/event-stream) 서버(text/event-stream + keep-slive)는 해당 요청을 수락하여 이벤트가 발생할 때마다 클라이언트로 메시지를 전달하면서 커넥션이 유지되게 된다.
  • SSE는 HTTP 기반이기에 방화벽, 프록시 서버를 통과하는데 문제가 없으며 단방향 통신이 필요한 서비스에 적합하다.
  • WebSocket과 달리 서버 설정이 덜 복잡하고, 클라이언트는 별도 라이브러리 없이 쉽게 구현이 가능하다.
  • 다만, Client의 수가 늘어날 수록 서버의 부담이 커지기에 Scale-out, 커넥션 유지 최적화 등 전략을 세워야 합니다.

예제

Spring SSE를 활용하여 래플에 응모하기

Spring에서 SSE를 활용해 래플에 응모 후 당첨 확률을 제공하는 프로토타입 기능을 구현하였습니다.
전체 코드는 깃헙에서 확인하시면 됩니다.

SSE + 분산서버

SSE를 사용할 때 단일서버에서는 큰 문제가 생기지는 않는다. SseEmitter가 서버 메모리에 저장되기 때문이다.
그래서 분산서버에서의 문제를 해결하기 위해 Redis의 Pub/Sub 을 이용하기로 하였다.

	/**
     * 래플 참여 시 래플 참여 정보를 저장하고, 래플 정보를 Redis로 Publish 한다.
     *
     * @param raffleId
     * @param memberId
     */
    public void participateInRaffle(Long raffleId, Long memberId) {
        Raffle raffle = raffleRepository.findById(raffleId)
            .orElseThrow(() -> new IllegalArgumentException("해당하는 래플이 존재하지 않습니다."));

        participationRepository.save(Participation.builder()
            .raffleId(raffle.getId())
            .memberId(memberId)
            .build());

        redisOperations.convertAndSend(
            RaffleChannelGenerator.getChannelName(String.valueOf(raffle.getId())),
            RaffleParticipationRequest.of(raffle, memberId)
        );
    }
    public SseEmitter subscribe(final Long raffleId) throws IOException {
        final String id = String.valueOf(raffleId);
        final SseEmitter emitter = createSseEmitter(id);
        
        // Redis에 새로운 이벤트가 발생하면 자동으로 onMessage 호출
        final MessageListener messageListener = createMessageListenerAndSendToClient(emitter, id);

        // redisMeesageListenerContainer에 새로운 MessageListener를 추가함
        // MessageListener들을 redisMessageListenerContainer에 추가하여 관리한다.
        addMessageListenerToContainer(messageListener, id);

		// emitter 완료되었는지 타임아웃이 났는지
        handleEmitterCompletionAndTimeout(emitter, messageListener);

        return emitter;
    }

테스트

1. 이벤트 구독

http://localhost:9998/api/v1/raffle/1/subscribe

2. 응모진행(데이터 발행)

3. 발행된 데이터가 전송됐는지 확인

  • 위와 같이 구독한 클라이언트에서는 이벤트를 전송받을 수 있다.
  • 샘플 코드에서는 id가 유니크하지 않다. 마지막 이벤트 데이터가 중요하지 않았기 때문이다.(깃헙에는 유니크한 id를 생성하도록 예제를 수정했으니 필요한 사람은 참고하면 좋을 것 같다.)

4. Redis Monitor

주의할 점

  • 우선 테크톡에서 포스팅된 내용을 보시면 좋은 인사이트를 얻을 수 있다고 생각합니다.

1. Timeout 설정

  • Timeout 설정을 길게 설정하는 것은 서버 입장에서 좋지 않다. 긴 수명을 가지게 된다면 커넥션 유지, 쓰레드의 퍼포먼스 저하 요소가 될 수 있기 때문이다. 때문에 적절한 Timeout 시간을 설정하는 것이 중요하다.
  • 서버에서의 커넥션 연결이 만료되어도 브라우저 레벨에서 자동으로 재연결 요청을 보내기 때문이다.

2. 503 Service Unavailable

  • 만료될때까지 서버에서 단 1개의 Event도 전송하지 않는 경우 위 에러가 발생하기 때문에 SseEmitter 객체를 생성하고 응답해주면 간단히 문제를 해결된다.

3. IOException, IllegalStateException

  • IOException의 경우 클라이언트에서 브라우저 새로고침 or 브라우저 종료 등이 발생하면 에러가 발생하게 된다.

  • IllegalStateException의 경우 Timeout이 발생해서 그런데 아래 사진 처럼 후처리에서 해당 객체들을 제거해주면 된다.

4. 재전송시 데이터 유실

  • EventStream이 만료되어 클라이언트에서 자동으로 재연결 요청하는 것이 SSE 장점이라고 할 수 있다.
  • 하지만, 이때 재연결을 위해 수초가 소요될 수 있는데, 재연결 중에 서버에서 Event를 전송하게 될 경우 유실될 수 있다.
    이러한 문제를 해결하기 위해 클라이언트가 마지막 수신 데이터가 무엇인지 알 수 있도록 id를 고유한 스펙으로 설계하였다.
  • SSE protocol에 따르면 클라이언트는 Optional하게 Last-Event-ID를 이용해서 마지막으로 본 이벤트를 서버에 알릴 수 있다.
  • 이를 통해 미수신한 이벤트도 수신할 수 있다.

마치며

특별한 이유가 있지 않는 이상 애플리케이션을 단일 서버로 구성하는 경우는 별로 없을 것이라고 생각합니다.
현재 저희 서비스 또한 여러 AZ에 걸쳐 분산 서버로 운영되고 있습니다. 그래서 분산 서버 환경에서 동기화 처리가 필요했고 레퍼런스를 조사하다보니 Redis의 Pub/Sub 구조를 선택하게 되었습니다.
Redis를 회사에서 사용중에 있었고 SQS와 같은 메세징 서비스와는 다르게 Producer가 생성한 이벤트를 가장 먼저 consume한 consumer만 해당 데이터를 처리할 수 있기 때문에 더욱 적절하다고 생각하였습니다.

현재 저희 서비스에서 특정 래플의 당첨 확률을 보여주고 있는데 확률을 보는 사이에 여러 사용자들이 래플에 응모하게 되면 그 당첨 확률을 변동하게 될 것입니다.
이러한 요구사항에 가장 적절한 방식이라고 생각했기에 SSE를 활용하게 되었습니다.
이번 SSE를 적용한 덕분에 현재는 SSE가 필요한 영역에서 적절하게 사용되고 있습니다.

참고
https://blog.bytebytego.com/p/network-protocols-behind-server-push
https://medium.com/techieahead/http-short-vs-long-polling-vs-websockets-vs-sse-8d9e962b2ba8
https://medium.com/@saurabh.singh0829/redis-pub-sub-implementation-f3208e4625c7

profile
문제 해결과 개선 과제를 수행하며 성장을 추구하는 것을 좋아합니다.

0개의 댓글