[HTTP] 실시간 알람 기능 구현하기(Server Sent Event) vs LongPolling vs Polling 방식과 비교

최혜원·2023년 12월 15일
1
post-thumbnail

실시간 통신의 방법

실시간 통신을 구현하기 위한 방법은 다양하다. 대표적인 방법인 Polling, Long Polling, SSE에 대해 알아보겠습니다.


Polling

위키백과에서 Polling 이란 하나의 장치(또는 프로그램)가 충돌 회피 또는 동기화 처리 등을 목적으로 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식을 말한다.

일정한 주기를 가지고 클라이언트가 서버와 응답을 주고 받는 방식
예를 들어 3초의 주기를 가지고 있다면 3초마다 새로운 데이터를 계속해서 얻어오는 방식이다.

그렇다면 이 polling이 왜 나오게 된 것인지 찾아보았는데 그것은 바로 HTTP의 특성과 관련되어 있다.

HTTP

HTTP 프로토콜의 가장 큰 특성은 비연결성 이다. 이 특성으로 인해 HTTP 프로토콜을 사용하는 웹페이지의 경우 클라이언트와 서버가 한 번 연결을 맺은 후 클라이언트 요청에 대해 서버가 응답을 마치면 맺었던 연결을 끊어버리는 성질을 말한다.(연결을 유지하기 위한 리소스를 줄이기 위해)

따라서 WebSocket이 등장하기 전 리얼타임 웹을 위한 기법으로 일정한 주기를 가지고 서버와 응답을 주고 받기 위해 polling이라는 방법을 사용하였다.

Polling에 대한 중요 특징

Polling의 정의를 곱씹어본다면 중요한 것을 알 수 있다. 바로 ‘주기’라는 것이다.
‘일정 주기 마다 데이터를 주고 받는다’는 것은 다른 말로 연결이 유지된다는게 아니라는 것이다.
요청마다 연결을 다시 생성하고 그 과정이 반복된다는 것이다 → 오버헤드의 위험성

  • 주기적으로 요청하여 결과를 확인하는 방식
  • 클라이언트가 http request를 서버로 계속 보내서 이벤트 내용을 전달 받는 방식

Polling의 주기

주기가 다면 서버에 더 자주 요청을 보내며, 실시간에 가까운 데이터 갱신을 목표. 그러나 서버에 부담이 커진다.
주기가 다면 서버에 덜 자주 요청을 보내며, 서버 부하를 줄이면서도 데이터를 정기적으로 갱신. 실시간 성능이 떨어진다.

Polling 방식의 단점

  • 클라이언트가 계속적으로 request를 보내기 때문에 클라이언트가 많아지면 서버의 부담이 급증한다.
  • http request connection을 맺고 끊는 것 자체가 부담스러운 방식이다.
  • 실시간 정도의 빠른 응답을 기대하기 어렵다.
  • http 오버헤드가 발생할 수 있다.
  • 이를 보완하기 위해 Long polling과 Streaming 방식이 존재한다.
  • 일정하게 갱신되는 서버 데이터의 경우 유용하게 사용된다.

Polling은 전송할 데이터의 유무에 관계 없이 주기적으로 요청을 수행하는 방법이다.
즉 실시간 통신을 위해 Polling 방법을 사용한다는 것은 주기를 짧게 설정해서 지속적인 통신이 이루어지는 것처럼 보이게 한다는 것이다. 하지만 이 과정에서 지속적으로 연결을 만들고 종료하는 과정이 반복되므로 선호되는 방법은 아니다.

클라이언트는 지정된 시간 간격에 맞춰 서버에 지속적인 요청을 보내고, 서버는 클라이언트에서 요청이 들어왔을 때 보낼 데이터가 있는 경우에는 해당 데이터를 응답의 바디에 넣어서 함께 보낸다.
서버에서 이벤트가 발생하지 않아 보낼 데이터가 없는 경우에는 불필요한 통신을 주고받게 되는 것이다.
또한 일정 간격으로 요청을 보내기 때문에 완벽한 실시간성을 보장할 수 없다.
이전 응답과 새로운 요청 사이에 이벤트가 발생하는 경우에는 클라이언트가 해당 이벤트를 수신할 수 없기 때문이다.
물론 요청 간격이 조밀하다면 어느 정도 실시간에 가깝겠지만 그만큼 불필요한 네트워크 리소스를 낭비하게 된다.
따라서, 폴링 방식은 서버 이벤트가 일정 간격으로 일어나는 경우가 아니라면 적합하지 않다.

구현방법

  • Polling을 Spring에서 구현하는 방법은 생각보다 간단하다.
    • Polling이란 ‘특정 주기마다 반복적 호출’이 핵심이다.
    • ‘반복’의 기능을 Spring 자체에서 구현하는 것보다는 클라이언트 로직에서 추가하는 것이 올바르다 판단해서 Spring에서는 단순한 API로만 남겨놓았다.

스프링

    @CrossOrigin(origins = "http://localhost:3000")
    @GetMapping(value ="/notifications/poll")
    public ResponseEntity<Page<NotificationResponseDto>> pollNotifications(Pageable pageable,
        @AuthenticationPrincipal UserDetailsImpl userDetails) {
        // 새 알림이 없더라도 빈 목록을 즉시 반환
        return ResponseEntity.ok(notificationService.notificationList(
            userDetails.getMember().getMemberNo(), pageable));
    }

리액트

    /*폴링*/
   useEffect(() => {
        handleGetAlarm(page);

        const interval = setInterval(() => {
            fetch('http://127.0.0.1:8080/api/notifications/poll', {
                method: 'GET',
                credentials: 'include',
                headers: {
                    'Authorization': isAuthorization,
                }
            })
            .then(response => response.json())
            .then(data => {
                handleGetAlarm();
            })
            .catch(error => {
                console.error("Error fetching notifications", error);
            });
        }, 3000); // 3초 간격으로 폴링(서버에 요청)

        return () => clearInterval(interval); // cleanup 함수로 interval 을 정리
    }, [isAuthorization]);


Long Polling

Polling 방식의 단점을 보완하기 위한 방법으로서 서버의 연결 시간을 좀 더 길게 유지하여 데이터를 주고받는 방식이다.

클라이언트에서 요청을 보냈을 때 서버가 응답을 바로 주는 것이 아니라 이벤트가 발생했을 때 혹은 타임아웃이 발생했을 때까지 기다렸다가(연결을 유지) 응답을 전달하는 방식
클라이언트는 서버가 반환한 응답을 받으면 다시 새로운 요청을 보낸다.
이 때, 응답은 데이터를 포함한 응답일 수도 있고 타임아웃 에러일 수도 있다.
기존 폴링 방식보다 실시간성이 높고 불필요한 요청이 줄어든다는 장점이 있지만,
이벤트 발생이 과도하게 발생하는 경우에는 그만큼 http 통신 횟수도 늘어나게 되므로 서버에 무리가 갈 수 있다.

  • 클라이언트가 서버에 요청을 보내면 서버는 클라이언트에게 즉시 응답 반환 X
    → 데이터가 업데이트되면 서버가 클라이언트에게 응답을 반환
  • 서버는 데이터를 업데이트할 때까지 클라이언트와의 연결을 유지(persist)함
  • 클라이언트는 서버로부터 응답을 받은 후에 새로운 요청을 보내어 다시 데이터를 가져옴
  • 폴링에 비해 서버의 부하가 적지만 데이터의 업데이트가 빈번할 경우 폴링과 별 차이 X

Long Polling에 대한 중요 특징

일반 polling(short polling)은 일정 주기로 계속 서버에 새 connection을 만든다.
반면 long polling을 connection을 열어 놓고, 서버가 응답을 줄 때까지 pending 상태로 기다린다. 응답을 받거나 pending connection이 끊기면(ex. 네트워크 에러) 해당 connection을 닫고 다시 새 connection을 열어 응답을 기다린다.

  • 서버에서 접속 시간을 길게하는 방식
  • 클라이언트에서 서버로 http request 전송
  • 서버 응답 데이터가 없으면 기다림. 이벤트가 존재하면 그 순간 response 메세지를 전달 후 연결 해제
  • 클라이언트에서 다시 http request를 전송하여 서버의 다음 이벤트 대기
  • 다수의 클라이언트에게 이벤트가 동시에 발생하면 서버 부담 급증

Long Polling의 주기

주기가 길다면 polling 방식보다 서버 부담이 줄지만, 이벤트 주기가 짧다면 Polling과 차이가 없다.


Long Polling 은 서버 측에서 응답을 일정시간 동안 대기 상태에 둬야 한다고 봤다.
Spring에서 Long Polling 기능을 구현하는 방법 중 대표적인 것은 DeferredResult를 이용하는 것이다.

Long Polling과 DeferredResult

시간 제한이 있는 DeferredResult를 사용하면 이 설정은 새 데이터를 사용할 수 있거나 시간 제한 기간이 경과할 때까지 요청을 열어두어 롱폴링을 실행한다. 이를 통해 서버 리소스를 절약하는 동시에 클라이언트에 거의 실시간 업데이트를 제공하는 방식으로 효율적인 폴링을 구현할 수 있다.

응답을 보유하는 Spring 비동기 처리 컨테이너인 DeferredResult를 반환
Spring 프레임워크는 이 요청을 열린 상태로 유지하고 DeferredResult가 완료될 때만 응답을 반환
이를 통해 서버가 새 알림을 기다리는 동안 클라이언트는 연결을 유지할 수 있다.
알림이 사용 가능하거나 시간 초과가 발생하면 업데이트 되는 DeferredResult 개체
메서드가 알림을 기다리는 동안 서버 스레드를 차단하지 않고 비동기적으로 처리하고 즉시 반환할 수 있다.
시간 초과 처리: 30초 시간 초과 내에 알림이 없으면 DeferredResult 는 구성에 따라 자동으로 빈 목록이나 시간 초과 응답을 반환

  • Spring MVC 에서 인바운드 HTTP 요청을 비동기적으로 다루고자 할 때 사용
  • 들어오는 요청을 다루기 위한 HTTP 작업 스레드가 다른 작업 스레드로 오프로드 할 수 있게 하여 대기시간이나 긴 컴퓨팅 시간을 좀더 효율적으로 이용할 수 있다.
  • worker가 setResult 를 호출할 때, 컨테이너 스레드는 요청한 클라이언트한테 응답을 허용한다.
  • 다운 스트림 시스템으로부터 절대 응답 받지 못할 경우를 위한 타임아웃 메커니즘은 필수이다.

벨덩 Spring MVC Long Polling

구현방법

스프링

@GetMapping("/notifications/longpoll")
    public DeferredResult<ResponseEntity<List<NotificationResponseDto>>> longPollNotifications(
        @AuthenticationPrincipal UserDetailsImpl userDetails) {

        // Timeout set to 30 seconds for long polling
        // 서버가 빈 결과로 응답하기 전에 새 알림을 기다리는 기간
        long timeout = 30000L;
        DeferredResult<ResponseEntity<List<NotificationResponseDto>>> output = new DeferredResult<>(timeout);

        // Invoke the service method to check for new notifications with polling
        notificationService.longPollNotifications(userDetails.getMember().getMemberNo(), output, timeout);

 		output.onCompletion(() -> log.info("Request completed: " + output.getResult()));
        output.onTimeout(() ->  log.info("Request timed out without new notifications."));

        // 응답을 보유하는 Spring 비동기 처리 컨테이너인 DeferredResult를 반환
        // Spring 프레임워크는 이 요청을 열린 상태로 유지하고 DeferredResult가 완료될 때만 응답을 반환
        // 이를 통해 서버가 새 알림을 기다리는 동안 클라이언트는 연결을 유지할 수 있다.
        // 알림이 사용 가능하거나 시간 초과가 발생하면 업데이트되는 DeferredResult 개체
        // 메서드가 알림을 기다리는 동안 서버 스레드를 차단하지 않고 비동기적으로 처리하고 즉시 반환
        // 시간 초과 처리: 30초 시간 초과 내에 알림이 없으면 DeferredResult는 구성에 따라 자동으로 빈 목록이나 시간 초과 응답을 반환
        return output;
    }
    // 롱폴링
       public void longPollNotifications(Long memberNo,
        DeferredResult<ResponseEntity<List<NotificationResponseDto>>> output, long timeout) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        long pollingInterval = 5; // Poll every 5 seconds

        // 서버 측에서 새 알림을 확인하기 위한 5초 간격을 설정
        ScheduledFuture<?> scheduledFuture = executor.scheduleWithFixedDelay(() -> {
            List<NotificationResponseDto> notifications = UnsentnotificationList(memberNo);
            if (!notifications.isEmpty()) {
                notifications.forEach(notification -> {
                    Notification notificationEntity = notificationRepository.findById(
                        notification.getNo()).orElse(null);
                    if (notificationEntity != null) {
                        notificationEntity.setIsSent(); // 알림을 보낸 것으로 표시
                        notificationRepository.save(notificationEntity); // 수정된 엔터티를 데이터베이스에 다시 저장
                    }
                });
                // 클라이언트에 응답을 반환. 그러면 실행 프로그램도 종료되어 추가 폴링이 중지된다.
                output.setResult(ResponseEntity.ok(notifications));
                log.info("output.setResult!??!??!?!!?"+output.getResult());
                executor.shutdown(); // 불필요한 리소스 사용 방지
            }
            // 5초마다 notificationList(memberNo) 메소드가 호출되어 사용자에게 새로운 알림이 있는지 확인
            // 알림이 발견되지 않으면 코드는 새 알림을 찾거나 시간 초과될 때까지 5초마다 계속 확인
        }, 0, pollingInterval, TimeUnit.SECONDS);

        output.onTimeout(() -> {
            log.info("Request timed out after " + timeout + " milliseconds.");
            output.setResult(ResponseEntity.ok(Collections.emptyList()));
            scheduledFuture.cancel(false);
            executor.shutdown();
        });
        output.onError(t -> {
            log.error("Error during long polling", t);
            executor.shutdown();
        });
    }

    // 룡폴링
    @Transactional(readOnly = true) // 클라이언트로 보내지지 않은 알람
    public List<NotificationResponseDto> UnsentnotificationList(Long memberNo) {
        return notificationRepository.findAllByReceiverNoAndIsSent(memberNo, false).stream()
            .map(NotificationResponseDto::of).toList();
    }

⭐️ 시간 초과 시 빈 목록을 다시 보내는 이유?

시간 초과 시 응답으로 빈 목록을 반환함으로써 서버는 클라이언트와 효율적으로 통신하여 보고할 알림이 없더라도 롱폴링 요청이 정상적으로 완료되었음을 양측이 이해할 수 있도록 한다. 빈 목록을 다시 보내면 서버는 폴링 요청이 성공했지만 지정된 제한 시간 동안 새 알림이 수신되지 않았음을 클라이언트에 알린다. 이렇게 하면 클라이언트는 통신에 실패가 없었음을 알 수 있다.
빈 목록으로 응답함으로써 서버는 클라이언트가 무기한 대기하는 유휴 상태를 방지한다. 서버가 응답 없이 단순히 연결을 닫은 경우 클라이언트는 이를 오류로 처리하여 혼란을 야기하거나 불필요한 재시도 논리를 초래할 수 있다.
빈 목록은 현재 폴링 주기의 정상적인 종료를 알리므로 클라이언트는 오류 처리나 특별한 재시도 없이 이를 처리하고 계획대로 진행할 수 있다.
롱폴링 연결은 서버 리소스를 소비한다. 빈 응답을 반환하면 서버는 리소스를 불필요하게 점유하는 대신 각 폴링 주기 후에 리소스를 해제할 수 있다.
클라이언트는 또한 서버에서 업데이트를 확인하는 빈도를 제어할 수 있으므로 예측 가능한 주기의 이점을 누릴 수 있다. 이는 클라이언트-서버 연결이 무기한 열려 있지 않고 정기적으로 새로 고쳐지도록 보장한다.
DeferredResult설정에서 서버는 새 데이터를 기다리는 시간을 명시적으로 정의한다. 새 알림 없이 지정된 시간이 지나면 서버는 시간 초과되고 빈 목록을 사용하여 요청이 성공적으로 처리되고 알림이 발생하지 않았음을 나타낸다.

  • Executor는 기능적으로 보면 Thread와 유사하여 Thread의 대체제로 생각할 수 있지만, 정확하게는 Runnable의 작업을 실행시키는 함수를 담은 인터페이스라고 한다.
  • DeferredResult를 직역하면 지연된 결과로 해석할 수 있다. 어떤 요청에 대한 응답 이벤트를 큐에 저장하고 있다가 DeferredResult.setResult() 메서드가 호출되면 DispatcherServlet에 응답을 보내는 형태로 이루어진다고 한다. 단, 스레드는 스프링 MVC에서 관리되지 않는다고 한다.

Servlet 비동기 요청 처리

ServletRequestrequest.startAsync()를 호출하여 비동기 모드로 전환할 수 있다. 이 경우에는 서블릿 및 모든 필터가 종료가 되어도 나중에 응답 처리를 완료할 수 있다.
request.startAsync()의 호출은 AsyncContext를 반환하는데, 이는 비동기 처리에 대한 추가 제어에 사용할 수 있다. 예를 들어, AsyncContext는 서블릿 API의 포워드 요청과 유사하지만 추가로 Servlet 컨테이너 스레드에 애플리케이션에 요청 처리를 재개할 수 있는 Dispatch 메서드를 제공한다.
ServletRequest는 현재 DispatcherServletType에 접근할 수 있게 하고 초기 요청인지, 비동기 요청인지, 포워드 요청인지, 기타 Dispatch 요청인지 구분할 수 있게 해준다.

DeferredResult 요청 처리 과정

  1. Controller는 DefferedResult를 반환하고 이를 메모리 내 큐나 리스트에 저장해 나중에 접근할 수 있도록 한다.
  2. 스프링 MVC는 request.startAsync()를 호출한다.
  3. 그 동안 DispatcherServlet과 모든 필터는 스레드를 종료하지만 응답은 아직 처리되지 않았다.
  4. 애플리케이션은 DeferredResult를 다른 스레드에서 처리하게 하고 스프링 MVC는 Servlet 컨테이너에 다시 요청을 Dispatch한다.
  5. DispatcherServlet은 다시 호출되고, 비동기 리턴값의 처리를 다시 재개한다.

리액트

  const longPoll = async () => {
        try {
            const response = await axios.get('http://localhost:8080/api/notifications/longpoll', {
                headers: {
                    Authorization: `${isAuthorization}`,
                    'Content-Type': 'application/json',
                },
                withCredentials: true, // This is equivalent to `credentials: 'include'` in fetch
                //timeout: 60000 // 60초로 변경 // Set timeout of 30 seconds for the request
            });
            console.log("?!?!??!?"+response.data); // Handle the response data as needed

            // Axios automatically throws an error for responses with status codes outside the 2xx range
            const newNotifications = response.data; // Axios automatically parses JSON

            if (newNotifications.length > 0) {
                console.log("?!?!??!?"+newNotifications);
                handleGetAlarm(); // 알람 조회
            }

            // Set a timeout for the next poll
            pollingTimeoutId = setTimeout(longPoll, 5000);// 성공적인 응답을 받으면 5초 지연 후 다음 폴링 요청이 발생하도록 설정

        } catch (error) {
            console.error("Error during long polling:", error);
            pollingTimeoutId = setTimeout(longPoll, 1000); // Retry on error
        }
    }

클라이언트와 서버의 관계, 5초 지연

  • 클라이언트 지연(setTimeout - 5초):
    이는 클라이언트가 새로운 롱폴링 요청을 시작하는 빈도를 제어한다.
    응답을 받은 후(데이터 포함 여부에 관계없이) 클라이언트는 다른 요청을 하기 전에 5초를 기다린다.

  • 서버 지연(scheduleWithFixedDelay - 5초):
    이는 서버가 새 알림을 확인하는 빈도를 제어한다.
    클라이언트 요청이 활성화된 동안 서버는 5초마다 새로운 알림이 있는지 확인한다.
    알림이 발견되면 서버는 즉시 응답하여 실행 프로그램을 중지한다.

알림이 발견되면 서버는 가능한 한 빨리 클라이언트에 응답하고 현재의 롱폴링 요청을 종료한다.
그러면 클라이언트는 다음 요청을 시작하기 전에 5초를 기다린다.
그 후 다시 요청 시도한다.
알림이 발견되지 않으면 서버는 알림을 찾거나 요청 시간이 초과될 때까지 5초마다 계속 확인한다.
(5초 지연 기간 내에 서버 측에서 새로운 알림이나 경보가 생성되면 클라이언트는 다음 롱폴링 요청이 시작되고 완료된 후에만 이를 인식하게 된다.)

요약하면 클라이언트와 서버 모두에서 5초 지연은 다음을 보장한다.
서버는 과도한 리소스 없이도 정기적으로 새 알림을 확인한다.
클라이언트는 요청 간의 지연을 존중하여 적시에 업데이트를 수신하는 동시에 과도한 서버 로드를 방지한다.

실행

1. 알람 전송

2. longPollNotifications 에서 notificationList 실행되어 클라이언트에 전해지지 않은 알람 조회

3. 클라이언트에 응답을 반환

사용자(오른쪽)가 채팅을보내면 관리자(왼쪽)에게 알람이 도착

채팅을 전송 시간 기준 관리자에게 알람이 전송되어 화면에 나타날 때까지의 시간을 측정했다.
-> 평균 179ms


Long Polling 방식의 단점

롱폴링에는 특히 WebSocket 또는 SSE(서버 전송 이벤트)와 같은 다른 방법과 비교할 때 실시간 성능에 영향을 미치는 제한 사항이 있을 수 있다. 실시간 컨텍스트에서 롱폴링이 덜 효율적일 수 있는 몇 가지 이유가 있다.

1. 지연 시간 및 지연된 업데이트

롱폴링에는 클라이언트가 서버에 반복적으로 요청을 보내는 것과 관련되며 각 요청은 응답을 기다리거나 지정된 기간 후에 시간 초과된다.
⭐️ 요청 사이에 클라이언트가 다시 연결될 때 짧은 지연이 발생하는 경우가 많다.
짧은 지연 시간 동안 이벤트가 발생하면 클라이언트 측에 즉시 반영되지 않아 업데이트가 즉각적이지 않을 수 있다.

 pollingTimeoutId = setTimeout(longPoll, 3000);// 성공적인 응답을 받으면 3초 지연 후 다음 폴링 요청이 발생하도록 설정
} catch (error) {
  console.error("Error during long polling:", error);
  // 1초 지연 후에 다시 호출되도록 예약
  pollingTimeoutId = setTimeout(longPoll, 1000); // Retry on error
}
  • 성공적인 응답 후 5초 지연 후 다시 호출 -> 5초 지연 기간 내에 서버 측에서 새로운 알림이 생성되면 클라이언트는 다음 롱폴링 요청이 시작되고 완료된 후에만 이를 인식하게 된다.
  • 타임아웃 에러 발생하면 1초 지연 후 다시 호출 -> 1초 간격 동안 발생하는 이벤트 누락이 발생할 가능성이 있다.

2. 네트워크 트래픽 및 리소스 사용량 증가

롱폴링은 새로운 데이터가 없더라도 서버에 반복적인 HTTP 요청을 보낸다. 각 요청을 개별적으로 처리해야 하므로 불필요한 네트워크 오버헤드가 추가되고 서버 부하가 증가할 수 있다.
채팅 애플리케이션이나 실시간 알림과 같은 실시간 업데이트의 경우 이러한 지속적인 폴링은 WebSocket과 같은 영구 연결에 비해 더 많은 네트워크 정체와 더 높은 서버 리소스 소비를 초래할 수 있다.

3. 서버 오버헤드

각각의 롱폴링 요청은 HTTP 연결을 열고 유지하며, 이는 데이터가 전송되거나 시간 초과가 발생할 때까지 열려 있다.
클라이언트가 많으면 열린 연결 수가 많아져 서버의 메모리와 연결 오버헤드가 증가할 수 있다.
이는 제한 시간과 연결 제한을 주의 깊게 관리하면 어느 정도 완화될 수 있지만 여전히 본질적인 한계가 있다.

4. 확장성 문제

각 클라이언트는 정기적으로 HTTP 요청을 하기 때문에 많은 수의 사용자를 지원하기 위해 롱폴링 시스템을 확장하는 것은 어렵고 비용이 많이 들 수 있다. 이와 대조적으로 WebSocket은 단일 연속 연결을 유지하므로 고주파수 실시간 데이터 교환을 위해 확장성이 뛰어나다.

실시간 성능을 위한 대안

애플리케이션에 실시간 성능이 중요한 경우 다음 대안을 고려할 수 있다.

WebSockets: 클라이언트와 서버 간에 지속적인 연결을 설정하여 지연 시간이 짧은 양방향 통신을 허용한다. WebSocket은 채팅 앱, 실시간 알림, 게임 등 지속적인 업데이트가 필요한 애플리케이션에 매우 적합하다.

서버 전송 이벤트(SSE): SSE를 사용하면 서버가 단일 HTTP 연결을 통해 클라이언트에 업데이트를 푸시할 수 있습니다. WebSocket과 달리 SSE는 단방향이지만 주식 시세 표시기나 실시간 뉴스 피드와 같이 서버에서 클라이언트로 실시간 데이터를 스트리밍하는 데 적합하다.

푸시 알림: 모바일 앱 알림과 같은 특정 유형의 실시간 데이터의 경우 푸시 알림은 업데이트를 장치에 직접 전달하여 롱폴링을 보완하거나 대체할 수 있다.

Long Polling이 적합한 경우

긴 폴링은 다음과 같은 경우에 좋은 선택이 될 수 있다.

  • 낮은 빈도의 업데이트 : 실시간 성능이 그다지 중요하지 않고 업데이트가 자주 발생하지 않는 경우
  • 간단한 서버 설정 : 표준 HTTP와 함께 작동하며 서버가 WebSocket 또는 SSE 프로토콜을 지원할 필요가 없다.
  • 단기 솔루션 또는 최소한의 확장 요구 : 애플리케이션의 사용자 기반이 작거나 실시간 업데이트가 최우선 순위가 아닌 경우
    요약하자면 롱폴링은 대기 시간, 트래픽 증가, 확장성 문제로 인해 실시간 성능을 저하시킬 수 있지만 이러한 요소가 중요하지 않은 특정 사용 사례에서는 여전히 실행 가능한 옵션이 될 수 있다.
  • 약 3초간의 오차로 실시간 응답이 필요한 경우
  • 메신저 같이 1:1이나 약 10명 이하의 상대와 채팅하는 경우
  • 채팅서버만 분리 할 수 있는 경우
    예) Facebook의 웹 채팅, Google 의 메신저, MSN의 웹 메신저 등


Server-Sent Events

SSE는 경우에 따라 WebSocket의 좋은 대체재가 될 수 있는 통신방식이다.
Restful API, SOAP, GraphQL 등 HTTP를 활용한 일반적인 방식들은 클라이언트가 서버에게 요청을 보내고 서버는 이러한 요청이 있을 때만 그에 대한 응답으로 답을 보내는 식으로 소통이 이뤄진다. 이 한계를 극복하기 위해 서버에서도 자유롭게 메세지를 보낼 수 있도록 즉 양방향 소통이 가능하도록 하는 방식이 WebSocket임을 설명했다. 하지만, 구현 및 서버 설정의 복잡성 등 WebSocket의 단점들도 볼 수 있었다. 서비스에 따라서는 양방향 통신이 아닌, 서버로부터의 단방향 통신으로 충분한 경우들도 있다. 즉, 서버 쪽에서 클라이언트에게 실시간으로 데이터를 보내기만 하면 되는 경우다. 이를테면 서버에서 시간이 걸리는 어떤 과정이 진행되고 있음을 보여주는 프로그레스 바가 있다. 이 화면을 구현하는 데 있어 클라이언트가 서버에게 지속적으로 어떤 데이터를 보낼 필요는 없다. 그 외에도 실시간으로 소식이나 동향이 올라오는 SNS, 뉴스 페이지, 주식 거래 서비스, 기타 실시간 모니터링 등 많은 사례들이 있다. 이와 같은 서비스들에서 SSE가 매우 유용하게 활용될 수 있다.

SSE의 구현과 동작 방식은 WebSocket에 비해 단순하다.
먼저, 클라이언트가 서버에 SSE로 통신하자는 요청을 보낸다.
즉, '지금부터 무슨 일이 있으면 나한테 말해줘. 난 듣기만 할게' 라고 서버에게 말해주는 것!
서버는 이 요청을 수신하고 수락했음을 알리는 메세지를 보낸다.
클라리언트는 이를 받고, 지금부터 서버가 보내주는 데이터들에 반응할 준비를 한다.
이 시점부터 서버는 정해진 이벤트가 있을 때마다 클라이언트에게 메세지를 보내게 된다.
단방향 통신이기 때문에 클라이언트는 이에 응답을 할 수는 없다.
클라이언트는 서버로부터 메세지가 도착할 때마다 이에 반응하여 화면을 업데이트하는 등 필요한 작업을 한다.
이 과정들은 하나의 연결 안에서 계속 이뤄지고, 만약 연결이 끊기면 클라이언트는 자동으로 재연결을 요청하여 통신을 재개한다.
필요한 작업을 마치면 클라이언트 또는 서버에서 상대방에게 종료를 통보하는 메세지를 보냄으로써 연결이 끝나게 된다.
이 과정은 HTTP를 통해서 이뤄지며 복잡한 코딩이나 설정을 필요로 하지 않는다.
서버 쪽에서만 메세지를 보내는 것이기 때문에 WebSocket과는 달리 로드밸런싱이 되어 있는 환경에서도 사용하기에 별다른 어려움이 없다.
이제 통신 과정을 자세히 살펴보자.

클라이언트가 웹사이트일 경우, 브라우저가 제공하는 JavaScript Web API 인 EventSource 객체가 SSE 에 사용된다. EventSource 는 HTML5 웹 표준에 정의되어 있으며, 현대적인 브라우저는 모두 이를 제공한다. 모바일 앱이나 클라이언트 역할의 서버 등 다른 환경에서는 해당 객체의 역할을 하는 라이브러리가 사용된다.

EventSource VS EventSourcePolyfill

EventSource

헤더 사용자 지정 제한: 네이티브 EventSource API는 서버에 요청을 보낼 때 사용자 정의 헤더(예: Authorization 헤더)를 수정하거나 추가할 수 없습니다. 이는 EventSource가 서버에서 클라이언트로 데이터를 푸시하기 위해 설계된 간단한 HTTP 연결을 사용하기 때문입니다. 연결을 설정할 때 기본적으로 GET 요청을 사용하며, 헤더를 수동으로 설정할 수 없다.
이 경우, 네이티브 EventSource 생성자를 사용하여 Authorization과 같은 사용자 정의 헤더를 직접 추가할 수 없다.
Web API로 주어지는 EventSource는 편리하지만, GET 요청 밖에 안되는 점, header 수정이 안되는 점 등의 한계가 있다.
polyfill 라이브러리를 통해 이 한계를 극복할 수 있다.

EventSourcePolyfill

사용자 정의 헤더 지원: Event-Source-Polyfill은 요청을 보낼 때 Authorization나 다른 필요한 헤더를 지정할 수 있도록 해준다. 이는 인증이 필요하거나 사용자 정의 헤더가 필요한 API와 작업할 때 중요한 장점이다.
여기서 Authorization과 같은 헤더를 포함할 수 있으며, 이는 네이티브 EventSource API로는 불가능하다.
CORS: Polyfill은 CORS 요청을 처리할 수 있지만 서버가 적절한 CORS 헤더로 응답해야 한다. 하지만 사용자 정의 헤더(예 :Access-Control-Allow-Origin)를 추가할 수 있기 때문에 보안 또는 인증된 API와 작업할 때 더 유연하다.
또한 자격 증명 관련 정보를 같이 보내고 싶은 경우엔 마찬가지로 withCredentials 옵션을 true로 설정해주어야 한다.

요약하자면, Event-Source-Polyfill은 사용자 정의 헤더를 허용하여 더 많은 유연성을 제공하므로 인증이나 추가 헤더가 필요한 경우에 더 나은 선택이 될 수 있다. 네이티브 EventSource는 더 간단하지만 이와 관련하여 제한적이다.

구현방법

React -> 알람 페이지 접속 시 api/notifications/subscribe 요청

  eventSource = new EventSourcePolyfill(`${host}/subscribe`, {
      headers: {
        Authorization: `${getCookie('Authorization')}`
      }
  });
	eventSource.addEventListener("open", function () {
      console.log("Connection opened");
  });
  • Connection opened

Spring

알람 구독

  public SseEmitter connectNotification(Long memberNo) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        emitterRepository.save(memberNo, emitter);
        emitter.onCompletion(() -> emitterRepository.delete(memberNo));
        emitter.onTimeout(() -> emitterRepository.delete(memberNo));
        try {
            log.info("send");
            // 클라이언트에게 연결 정보 제공
            emitter.send(SseEmitter.event()
                .id("id")
                .name(ALARM_NAME)
                .data("connect completed"));
        } catch (IOException exception) {
            throw new BusinessException(ErrorCode.NOTIFICATION_CONNECT_ERROR);
        }
        return emitter;
    }
  • connect completed

알람 전송

 @Transactional
    public void send(NotificationType type, NotificationArgs args, Long receiverNo) {
        log.info("Receiver MemberNo: " + receiverNo);

        // 알림 생성 및 저장 /*TODO :주석 해제하기*/
        Notification notification = Notification.toEntity(type, args, receiverNo);
        notificationRepository.save(notification); // save alarm

        // 알람을 받아 볼 member 가 브라우저에 접속을 한 상태여야,
        // 알람페이지를 한번 들어간 상태여야지(subscribe) 인스턴스를 만들어서 가지고 있다.
        // 주어진 회원 번호에 대한 emitter(이벤트 발송기) 검색
        Optional<SseEmitter> emitterOptional = emitterRepository.get(receiverNo);

        // emitter(이벤트 발송기)의 존재 여부를 로그로 기록
        if (emitterOptional.isPresent()) {
            log.info("Emitter found for MemberNo: " + receiverNo);
        } else {
            log.info("No emitter found for MemberNo: " + receiverNo);
        }

        // 디버깅: emitter(이벤트 발송기)가 존재할 경우 해당 내용을 로그로 기록
        log.info("EmitterRepository.get(): " + emitterOptional.orElse(null));

        // emitter(이벤트 발송기)가 존재하는 경우 알림 전송을 진행
        emitterOptional.ifPresentOrElse(
            sseEmitter -> {
                try {
                    NotificationEvent notificationEvent = NotificationEvent.builder()
                        .type(notification.getNotificationType())
                        .args(notification.getArgs())
                        .receiverNo(notification.getReceiverNo())
                        .build();

                    // SSE 이벤트를 전송
                    sseEmitter.send(SseEmitter.event()
                        .id(notification.getNo().toString())
                        .name(ALARM_NAME)
                        .data(objectMapper.writeValueAsString(notificationEvent))); // JSON 형식으로 전송
                    log.info(
                        "Notification sent successfully to MemberNo: " + receiverNo);
                } catch (IOException exception) {
                    // 전송 실패 시 오류를 기록하고 처리
                    // IOException : SSE 데이터를 클라이언트에게 전송하는 과정에서 발생하는 오류
                    log.error("Failed to send notification. Removing emitter for MemberNo: "
                        + receiverNo, exception);
                    emitterRepository.delete(receiverNo);
                    throw new BusinessException(ErrorCode.NOTIFICATION_CONNECT_ERROR);
                }
            },
            () -> log.info("No emitter found for MemberNo: " + receiverNo)
        );
    }

실행 화면

채팅을 전송 시간 기준 관리자에게 알람이 전송되어 화면에 나타날 때까지의 시간을 측정했다.
-> 평균 5ms


과정 설명

  1. 클라이언트는 지정된 URI로 서버에게 요청을 보낸다. 해당 요청에는 SSE 통신을 통해 이벤트 스트림이라는 형식의 메세지를 수신하겠다는 의미의 헤더가 실린다.
  2. 이를 수신한 서버는 해당 형식으로 작성된 메세지임을 명시하는 헤더를 실은 응답을 보낸다. 헤더에는 연결을 계속 유지한다는 이 항목도 실린다. 이 헤더 때문에 클라이언트가 요청을 보내면서 만들어진 연결이 서버가 응답을 보낸 이후로도 계속 유지되는 것이다. 이하나의 TCP 연결을 계속 유지하기 때문에 SSE 서버는 큰 부담을 받지 않고 지속적인 메세지 전송을 할 수 있다. 서버의 첫 번째 응답 이후로는 메세지에 HTTP 헤더가 실릴 필요가 없게 된다. 이제 서버는 지정된 이벤트가 발생할 때마다 실시간으로 클라이언트에게 메세지들을 보내고 클라이언트에서는 EventSource 또는 이에 해당하는 객체가 각 메세지에 반응하여 해당하는 프론트엔드 작업을 실행한다. 서버에서 보내는 메세지들은 텍스트 기반이며 이와 같은 형식을 가질 수 있다. data 필드에 담은 텍스트를 하나 또는 여럿씩 보낼 수 있고, 각 메세지에 id를 지정하여 보낼 수도 있다. 그렇게 하면 연결이 끊어졌다가 재개될 경우, 클라이언트가 마지막으로 받은 id를 요청에 실어보냄으로써 어디서부터 다시 받아야할지 명시할 수 있게 된다. 이벤트 유형을 명시할 수도 있다. 클라이언트는 각 유형에 어떻게 반응할지를 EventSource 객체 등에 등록된 리스너에 명시할 수 있다. retry는 연결이 끊어질 시 재접속을 몇 밀리초 후에 시도할지를 지정할 때 사용된다. 콜론으로 시작하는 메세지는 주석이며 클라이언트에서는 무시된다. 이는 서버에서 연결상태를 확인하거나 디버깅 정보를 전달하는 용도로 사용된다.
    브라우저의 EventSource 객체는 자동 재접속 기능을 내장하고 있다. 때문에 클라이언트가 따로 구현해두지 않아도 연결이 비정상적으로 끊길 때마다 자동으로 다시 요청을 보낸다.

💥 기본적으로 SseEmitter 생성할 때 1시간을 지정했는데, 연결이 끊기는 문제가 발생했다.

spring

private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;
...
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);

react

 eventSource.addEventListener("error", function (event) {
     console.log("Error occurred:", event);
     if (event.target.readyState === EventSource.CLOSED) { // 연결이 닫힌 경우 (추가)
       console.log("Connection closed, attempting to reconnect...");
       // 3초 후 connect() 함수를 사용하여 다시 연결을 시도
       setTimeout(() => connect(), 3000);
     }
 });

심지어 받은 메세지들에 id 가 포함되어 있을 경우 이를 Last-Event-ID 헤더로 실어보내서 서버가 그 다음에 해당하는 메세지부터 보낼 수 있도록 해준다.
클라이언트에서 연결을 종료할 때는 EventSource 객체의 close() 메소드를 호출하면 된다.
그러면 이벤트 리스너들이 해제되고 서버는 연결의 종료를 감지하여 전송을 종료하게 된다. 서버 쪽에서 연결을 종료하려면 전송을 중단하거나 클라이언트에게 합의도니 메세지를 보내어 통지하면 된다. 클라이언트에서는 이에 반응하여 EventSource 를 닫게 된다.

정리하자면 SSE는 WebSocket에 비해 다음과 같은 강점을 갖는다.

  1. 구현이 간편하다. WebSocket 처럼 설정 등이 까다롭지 않고 보틍은 따로 라이브러리를 설치하지 않고도 쉽게 개발할 수 있다.
  2. SSE는 HTTP 기반이기 때문에 방화벽에도 친화적이다. 원래 열려있는 80번, 443번 포트를 그대로 사용하기 때문에 웹소켓처럼 따로 포트 관련 설정을 해 줄 필요가 없다. 프록시 서버를 통과하는 데 있어서도 HTTP를 사용하는 SSE는 특별히 문제가 없지만 WebSocket 은 추가적인 작업이 들 수 있다.

이런 점들을 고려할 때,
서버로부터 단방향 통신으로 구현 가능한 서비스라면 SSE를,
양방향 통신이 필수적인 경우에는 WebSocket 등을 사용하면 된다는 결론을 내릴 수 있겠다.

서버와 한번 연결을 맺고나면 일정 시간동안 서버에서 변경이 발생할 때마다 데이터를 전송받는 방법이다.
Long Polling과 달리 서버에서 변경이 발생해도 다시 재연결 할 필요가 없다.
최대 연결 수에 제한이 있다.

✔️ 프로젝트에 SSE 적용 목록

  1. 채팅 시 : 일반사용자인 경우 관리자들에게 알람 / 관리자인 경우 채팅방 만든 사람에게 알람
  2. 문의사항 남기면 관리자에게 알람(from 문의사항 생성한 사람) / 문의사항 답변 시 남긴 사용자에게 알람 (from 로그인한 관리자)
  3. 상품 댓글 시 상품에 대한 판매자 번호가 등록되어 있지 않으면 관리자에게 알람 (from 댓글 단 사람)
  4. 상품 좋아요 시 상품에 대한 판매자 번호가 등록되어 있지 않으면 관리자에게 알람 (from 좋아요 누른 사람)
  5. 시장 좋아요 시 관리자에게 알람(from 좋아요 누른 사람)
  6. 상점 좋아요 시 상점 판매자 번호가 등록되어 있지 않으면 관리자에게 알람(from 좋아요 누른 사람)

관리자로 로그인한 경우

profile
어제보다 나은 오늘

0개의 댓글