실시간 알림 Server Sent Events(ing)

박세건·2024년 10월 8일
0

기술 실습

목록 보기
14/18
post-thumbnail

실시간 알림 구현 종류

  • WebSocket:
    • 양방향 통신이 가능하여 실시간 데이터 전송에 적합합니다.
      연결이 유지되는 동안 지속적으로 데이터를 주고받을 수 있어 효율적입니다.
      초기 설정이 복잡할 수 있으며, 서버 리소스를 많이 사용할 수 있습니다.
      방화벽이나 프록시 서버에 의해 차단될 수 있습니다.
  • Server-Sent Events (SSE):
    • 구현이 간단하고 HTTP 기반이므로 기존 인프라와의 호환성이 좋습니다.
      서버에서 클라이언트로 데이터를 자동으로 푸시할 수 있습니다.
      클라이언트에서 서버로는 데이터 전송이 불가능하며, 단방향 통신만 지원합니다.
      브라우저 지원이 제한적일 수 있습니다 (IE는 지원하지 않음).
  • Push Notification:
    • 사용자가 웹사이트를 닫아도 알림을 받을 수 있어 사용자 경험이 향상됩니다.
      다양한 브라우저에서 지원되며, 모바일에서도 사용할 수 있습니다.
      사용자가 알림 수신에 동의해야 하며, 이를 위한 사용자 정보를 관리해야 합니다.
      구현이 상대적으로 복잡하고, Service Worker에 대한 이해가 필요합니다.
  • Polling:
    • 구현이 간단하고, 기존 HTTP 요청을 사용하므로 호환성이 높습니다.
      서버가 클라이언트의 요청에 따라 데이터를 제공할 수 있습니다.
      서버에 부하를 줄 수 있으며, 네트워크 트래픽이 증가할 수 있습니다.
      실시간성이 떨어지고, 알림의 지연이 발생할 수 있습니다.
  • Firebase Cloud Messaging (FCM):
    • 다양한 플랫폼에서 푸시 알림을 쉽게 관리할 수 있습니다.
      Google의 인프라를 통해 안정성과 확장성을 제공합니다.
      Firebase에 의존해야 하며, 외부 서비스에 대한 추가적인 설정이 필요합니다.
      사용자의 동의 및 설정 관리가 필요합니다.

우리는 실시간알림이 필요했고 FCM 방식과 SSE 방식을 고민했다.
둘다 실시간 알림을 전달하는 데 사용할 수 있는 기능
FCM은 Firebase SDK를 통해 작동 -> 추가 라이브러리 설정 및 Firebase 서비스에 의존
SSE는 웹 브라우저에서 기본 지원으로 추가 라이브러리 없이 쉽게 구현 가능
또한, PWA를 사용했기 때문에 App보다는 Web 환경에 어울리는 기술을 선택하는 것이 맞다고 생각하여, SpringBoot를 사용해서 SSE 기술로 실시간 알림을 구현

주요 특징:

  • 단방향 통신: 서버에서 클라이언트로만 데이터가 전송됩니다. 클라이언트가 서버에 요청하는 방식은 아닙니다.
  • 연결 유지: 클라이언트가 서버에 요청을 보내면, 서버와의 연결이 유지되며, 서버는 필요한 경우 클라이언트에게 데이터를 푸시할 수 있습니다.
  • 텍스트 기반: SSE는 일반적으로 텍스트 기반으로 데이터를 전송하며, JSON 형식으로 데이터를 주고받는 경우가 많습니다.
  • 자동 재연결: 연결이 끊어지면 클라이언트는 자동으로 재연결을 시도합니다.

Repository

public interface NotificationRepository extends JpaRepository<Notification, Long> {

       boolean existsByReceiverIdAndRead(Long userId, boolean isRead);

}

Service


    @Override
    public boolean getStatus(Long userId) {
        return notificationRepository.existsByReceiverIdAndRead(userId, false);
    }

Controller

실시간으로 SSE 처리하기위해서 메인 스레드가 아닌 별도의 스레드에서 주기적인 작업을 수행하도록 설정
덕분에 클라이언트와의 연결을 유지하면서 다른 요청을 처리할 수 있음

스레드 선택

단일 스레드 :

  • 구현과 유지보수가 간단
  • 리소스 절약
  • 동기화 문제 없음
  • 클라이언트 수가 적을때 효율적

다중 스레드 :

  • 병렬 처리 -> 응답 향상
  • CPU 활용도 향상
  • 클라이언트 수가 많을때 효율적

따라서, 현재 웹앱 서비스는 적은 수의 클라이언트를 보유하기에 단일 스레드로 진행하고 추후 클라이언트 수가 증가한다면 다중으로 리팩토링하기로 결정

@GetMapping(value = "/status", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter handleSse() {
        // 무제한 타임아웃 설정을 가진 SseEmitter 객체 생성
        SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);

        // 단일 스레드로 작업을 스케줄링 할 수 있는 ScheduledExecutorService 생성
        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

        // 5초마다 주기적으로 작업을 수행하도록 설정
        executor.scheduleAtFixedRate(() -> {
            try {
                // 사용자 상태를 가져와서 "1" 또는 "0"으로 변환
                String result = notificationService.getStatus(UserHeaderInterceptor.userId.get()) ? "1" : "0";
                // 클라이언트에 결과 전송
                sseEmitter.send(result);
                // 로그에 전송된 정보 기록
                log.info("정보를 한번 보내볼게요 : " + result);
            } catch (IOException e) {
                // IOException 발생 시, SSE 연결 종료 처리
                log.info("정보 보내는 거 종료1");
                sseEmitter.completeWithError(e); // 오류와 함께 SSE 완료
                executor.shutdown(); // 스케줄러 종료
            }
        }, 0, 5, TimeUnit.SECONDS); // 0초 후 시작하고, 5초 간격으로 반복

        // SSE가 완료되면 스케줄러 종료
        sseEmitter.onCompletion(executor::shutdown);
        // SSE가 타임아웃되면 스케줄러 종료
        sseEmitter.onTimeout(executor::shutdown);

        // 로그에 SSE 시작 메시지 기록
        log.info("정보 보내는 거 종료2");

        // SseEmitter 반환하여 클라이언트 연결을 유지
        return sseEmitter;
    }

다중 스레드 버전

@GetMapping(value = "/status", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handleSse() {
    // 무제한 타임아웃 설정을 가진 SseEmitter 객체 생성
    SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);

    // 스레드 풀 생성 (예: 최대 5개의 스레드)
    ScheduledExecutorService executor = Executors.newFixedThreadPool(5);

    // 5초마다 주기적으로 작업을 수행하도록 설정
    executor.scheduleAtFixedRate(() -> {
        try {
            // 사용자 상태를 가져와서 "1" 또는 "0"으로 변환
            String result = notificationService.getStatus(UserHeaderInterceptor.userId.get()) ? "1" : "0";
            // 클라이언트에 결과 전송
            sseEmitter.send(result);
            // 로그에 전송된 정보 기록
            log.info("정보를 한번 보내볼게요 : " + result);
        } catch (IOException e) {
            // IOException 발생 시, SSE 연결 종료 처리
            log.info("정보 보내는 거 종료1");
            sseEmitter.completeWithError(e); // 오류와 함께 SSE 완료
            executor.shutdown(); // 스케줄러 종료
        }
    }, 0, 5, TimeUnit.SECONDS); // 0초 후 시작하고, 5초 간격으로 반복

    // SSE가 완료되면 스케줄러 종료
    sseEmitter.onCompletion(() -> {
        executor.shutdown(); // SSE 완료 시 스케줄러 종료
        log.info("SSE 연결이 종료되었습니다.");
    });
    // SSE가 타임아웃되면 스케줄러 종료
    sseEmitter.onTimeout(() -> {
        executor.shutdown(); // 타임아웃 시 스케줄러 종료
        log.info("SSE 연결이 타임아웃되었습니다.");
    });

    // 로그에 SSE 시작 메시지 기록
    log.info("정보 보내는 거 시작");

    // SseEmitter 반환하여 클라이언트 연결을 유지
    return sseEmitter;
}

Frontend

<script>
        const eventSource = new EventSource('https://j11a604.p.ssafy.io/api/notifications/status');

        eventSource.onmessage = function(event) {
            const eventsDiv = document.getElementById('events');
            eventsDiv.innerHTML += `<p>${event.data}</p>`;
        };

        eventSource.onerror = function(event) {
            console.error("Error occurred:", event);
            eventSource.close();
        };
    </script>

읽지않은 메일이 있다면 "1", 없다면 "0" 이 event.data에 저장
profile
멋있는 사람 - 일단 하자

0개의 댓글