본문 바로가기
공부

[BE] SSE 재연결과 관련된 시간

by 꾸돼지 2025. 6. 12.
320x100

이것저것 테스트하던 중, 가장 기본적인... 재연결 로직이 정상적으로 동작하지 않는 것을 확인했다.

 

 

SSE 연결은 클라이언트에서 요청을 보내고 (빨간색 화살표), 서버는 해당 연결을 OS에 반환하지 않고 유지하는 기법이다.

서버에서 클라이언트로 메시지를 보내야 할 때, 보관하고 있던 응답으로 클라이언트에 메시지를 전송한다.

초록색은 서버에서 complete가 실행되는 순간이다. 서버는 연결 종료를 통보하고, 클라이언트는 재연결을 요청한다.

이와 관련된 시간을 설정해줘야 한다.

 

 

1. timeout

SseEmitter는 연결이 끊어졌지만 유지되고 있는 좀비 연결을 방지하기 위해 Timeout 시간을 설정할 수 있다.

서버는 설정된 시간이 지난 SseEmitter를 처리한다.

    private SseEmitter createEmitter(Long userId) {
        SseEmitter emitter = new SseEmitter(SSE_CONNECTION_TIMEOUT);

        emitter.onTimeout(() -> {
            log.info("SSE Emitter 시간 제한: {}", userId);
            emitter.complete();
        });
        emitter.onCompletion(() -> {
            log.info("SSE Emitter 완료: {}", userId);
            emitterRepository.deleteById(userId);
        });
        emitter.onError(e -> {
            log.error("SSE Emitter 에러: {}: {}", userId, e.getMessage());
            emitter.complete();
        });


        emitterRepository.save(userId, emitter);

        return emitter;
    }

 

2. reconnectTime

서버는 클라이언트로 메시지를 보낼 때, 재연결 대기 시간을 지정해서 보낼 수 있다.

클라이언트가 서버와 연결이 끊긴 것을 감지하면, 마지막에 보낸 재연결 대기시간을 대기한 후, 새 요청을 서버로 전송한다.

    private void sendToClient(SseEmitter emitter, Long userId, String eventName, Object data) {
        try {
            emitter.send(SseEmitter.event()
                    .id(String.valueOf(System.currentTimeMillis())) // 고유 ID
                    .name(eventName)
                    .data(data)
                    .reconnectTime(1000 * 1L));
            log.info("사용자 {} 에게 {}으로 {}가 전송되었습니다.", userId, eventName, data);
        } catch (IOException e) {
            emitterRepository.deleteById(userId);
            log.error("사용자 {}에게 알림 전송이 실패했습니다.: {}", userId, e.getMessage());
        }
    }

 

 

 

 

그런데 이 부분은 서버 로그와 개발자도구의 네트워크에서 확인했을 때, 원하는 대로 동작하지 않는 것 같다.

더보기

INFO  25-06-12 01:07:12[http-nio-9001-exec-3] [NotificationRestController:26] - 사용자 SSE 연결 시작 : 1 
INFO  25-06-12 01:07:12[http-nio-9001-exec-3] [NotificationServiceImpl:26] - SSE 연결 시작 : 1
INFO  25-06-12 01:07:12[http-nio-9001-exec-3] [NotificationServiceImpl:90] - 사용자 1 에게 connection으로 SSE 연결이 성공했습니다. [userId=1]가 전송되었습니다.
INFO  25-06-12 01:07:16[http-nio-9001-exec-4] [NotificationServiceImpl:65] - SSE Emitter 시간 제한: 1
INFO  25-06-12 01:07:16[http-nio-9001-exec-4] [NotificationServiceImpl:69] - SSE Emitter 완료: 1
INFO  25-06-12 01:07:40[http-nio-9001-exec-6] [NotificationRestController:26] - 사용자 SSE 연결 시작 : 1 
INFO  25-06-12 01:07:40[http-nio-9001-exec-6] [NotificationServiceImpl:26] - SSE 연결 시작 : 1
INFO  25-06-12 01:07:40[http-nio-9001-exec-6] [NotificationServiceImpl:90] - 사용자 1 에게 connection으로 SSE 연결이 성공했습니다. [userId=1]가 전송되었습니다.
INFO  25-06-12 01:07:43[http-nio-9001-exec-7] [NotificationServiceImpl:65] - SSE Emitter 시간 제한: 1
INFO  25-06-12 01:07:43[http-nio-9001-exec-7] [NotificationServiceImpl:69] - SSE Emitter 완료: 1
INFO  25-06-12 01:08:11[http-nio-9001-exec-1] [NotificationServiceImpl:65] - SSE Emitter 시간 제한: 1
INFO  25-06-12 01:08:11[http-nio-9001-exec-1] [NotificationServiceImpl:69] - SSE Emitter 완료: 1
INFO  25-06-12 01:08:51[http-nio-9001-exec-5] [NotificationRestController:26] - 사용자 SSE 연결 시작 : 1 
INFO  25-06-12 01:08:51[http-nio-9001-exec-5] [NotificationServiceImpl:26] - SSE 연결 시작 : 1
INFO  25-06-12 01:08:51[http-nio-9001-exec-5] [NotificationServiceImpl:90] - 사용자 1 에게 connection으로 SSE 연결이 성공했습니다. [userId=1]가 전송되었습니다.
INFO  25-06-12 01:09:22[http-nio-9001-exec-7] [NotificationServiceImpl:65] - SSE Emitter 시간 제한: 1
INFO  25-06-12 01:09:22[http-nio-9001-exec-7] [NotificationServiceImpl:69] - SSE Emitter 완료: 1
INFO  25-06-12 01:09:23[http-nio-9001-exec-9] [NotificationRestController:26] - 사용자 SSE 연결 시작 : 1 

 

개발자도구 재연결 네트워크 그래프

 

도저히 왜 정상적인 시간에 메시지가 전달이 안되었는지 확인 중이었는데, GC와 상관이 있었다.

Grafana GC 그래프

 

잘 모르겠는 부분은, GC에 걸린 시간에 비해 너무 오랜 시간 스레드가 멈춰 있었던 거 같은데......

이 부분은 계속 살펴봐야겠다.

 

3. heartbeat

클라이언트는 서버와 정상적으로 연결되었는지 주기적으로 신호를 주고받아야 한다.

네트워크 장비들, 프록시 서버, 로드밸런서 등 모두 자원 보호를 위해 유휴(idle) 상태의 연결을 통보 없이 종료할 수 있다.

따라서 클라이언트는 생존신호를 서버에서 정해진 시간마다 수신하고, 이를 통해 새 연결 요청이 필요한 지 감지할 수 있다.

    const eventSource = new EventSourcePolyfill(subscribeUrl, { heartbeatTimeout: 25 * 1000 });

 

서버는 클라이언트와 약속된 생존신호를 전달할 수 있다. 나는 이 부분은 구현하지 않았지만, 참고로 남겨둔다.

    // ❗ 하트비트 스케줄러 초기화
    @PostConstruct
    public void init() {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        // 20초마다 모든 클라이언트에게 하트비트 전송
        scheduler.scheduleAtFixedRate(this::sendHeartbeatToAll, 0, 20, TimeUnit.SECONDS);
    }
    
    // ❗ 모든 활성 Emitter에게 하트비트를 보내는 메서드
    private void sendHeartbeatToAll() {
        emitters.forEach((userId, emitter) -> {
            try {
                // SSE 주석(comment)을 사용하여 하트비트 전송
                emitter.send(SseEmitter.event().comment("heartbeat"));
                log.trace("Sent heartbeat to user {}", userId);
            } catch (IOException e) {
                log.warn("Failed to send heartbeat to user {}, removing emitter.", userId);
                // 에러 발생 시 해당 emitter는 비활성 상태이므로 맵에서 제거
                emitters.remove(userId);
            }
        });
    }

 


 

하트비트 주기 < 중간 장비의 유휴 타임아웃(보통 60초) < SseEmitter 타임아웃

 

SSE와 관련된 시간 설정은 Gemini가 위와 같이 추천해줬다. 나중에 필요하게 되면 다음의 시나리오를 잘 살펴봐야겠다.

 

하트비트 (서버 전송 간격) :  20-30초

 - 너무 짧으면 불필요한 트래픽이 발생하고, 너무 길면 중간 장비의 타임아웃에 걸림

하트비트 (클라이언트 설정) : 45-60초

 - 서버의 하트비트 주기보다 충분히 길게 설정하여, 한두번의 하트비트가 네트워크 지연으로 누락되더라도 바로 연결을 끊지 않도록 설정

SseEmitter Timeout (서버) : 60초 이상

 - 하트비트가 정상적으로 동작하는 한 이 타임아웃 발생하지 않음. 모든 것이 실패했을 때를 위한 최후의 방어선

 

더보기

INFO  25-06-12 02:10:42[http-nio-9001-exec-9] [NotificationServiceImpl:25] - SSE 연결 시작 : 1
INFO  25-06-12 02:10:42[http-nio-9001-exec-9] [NotificationServiceImpl:89] - 사용자 1 에게 connection으로 SSE 연결이 성공했습니다. [userId=1]가 전송되었습니다.
INFO  25-06-12 02:10:49[http-nio-9001-exec-1] [NotificationRestController:32] - 123
INFO  25-06-12 02:10:49[http-nio-9001-exec-1] [NotificationServiceImpl:89] - 사용자 1 에게 push-message으로 123가 전송되었습니다.
INFO  25-06-12 02:10:52[http-nio-9001-exec-3] [NotificationRestController:32] - 123
INFO  25-06-12 02:10:52[http-nio-9001-exec-3] [NotificationServiceImpl:89] - 사용자 1 에게 push-message으로 123가 전송되었습니다.
INFO  25-06-12 02:11:12[http-nio-9001-exec-7] [NotificationServiceImpl:64] - SSE Emitter 시간 제한: 1
INFO  25-06-12 02:11:12[http-nio-9001-exec-7] [NotificationServiceImpl:68] - SSE Emitter 완료: 1
INFO  25-06-12 02:11:14[http-nio-9001-exec-8] [NotificationRestController:26] - 사용자 SSE 연결 시작 : 1 
INFO  25-06-12 02:11:14[http-nio-9001-exec-8] [NotificationServiceImpl:25] - SSE 연결 시작 : 1
INFO  25-06-12 02:11:14[http-nio-9001-exec-8] [NotificationServiceImpl:89] - 사용자 1 에게 connection으로 SSE 연결이 성공했습니다. [userId=1]가 전송되었습니다.

 

 

이것도 잘 동작하지 않는건 똑같다. timeout이 30초인데, push-message를 send해도 정확히 12초에 timeout된다.

이 내용은 내일 검토해야겠다. 아마도 이 부분과 관계가 깊어보인다.

 

https://saaaayho.tistory.com/18

 

SSE와 Security Filter

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Unable to handle the Spring Security Exception because the response is already committed.] with root causeCannot render error page for request [null] as the response

saaaayho.tistory.com

 

320x100