SSE 로 서버에서 클라이언트로의 단방향 통신을 구현하며 생긴 궁금증
- 기본적으로 http는 비연결성, 무상태 프로토콜로 한번의 요청과 응답으로 역할이 끝나게 된다.
- 하지만 SSE는 구독 요청 → 특정 이벤트 발생 시 요청이 오지 않음에도 서버에서 클라이언트로 데이터를 전송할 수 있다.
- 이러한 기술을 구현할 수 있도록 하는 것 ! → http persistence connection이라는 것 까지는 도달할 수 있었다.
- 의문점
- spring에서 사용하는 서블릿 컨테이너에 스레드 풀 기본 값은 200이다.
- SSE를 구현할 시 동시에 200명이 SSE알림을 받기 위해 구독을 한 상황
- 이때 서버에서 클라이언트로 데이터를 계속 보내기 위해서는 연결을 지속해야한다. 그 말은 어떠한 서버자원을 계속 점유하고 있어야한다.(I/O발생 끊기지 않은 상황)
- 그렇다면 그 이후로 다른 request가 왔을 때 큐 에서 계속 대기해야하는 상황이 생기지 않을까..???? 모든 스레드 자원이 연결을 지속하기 위한 목적으로 반납되지 않는 상황이라고 생각되었다.
- 해소
- 나와 같은 생각을 가진 질문이 10년전에 올라왔었고 답변들을 보면
- 서버의 스레딩 모델에 따라 다르고 apache는 기본적으로 연결당 하나의 스레드를 사용하므로 대기 한다고 나와있다.
- 동접자수가 특정값을 넘어간다면 폴링이 더 적합한 선택일 지도 모른다.
- 비동기적인 처리를 한다면 이를 해결 할 수 있을지도..?(NIO, webflux등 공부해봐야겠다.)
- https://stackoverflow.com/questions/14225501/server-sent-events-costs-at-server-side
2023-06-25
해당 문제에 대한 테스트를 진행해보았다.
package com.example.ssetest.controller;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@RestController
public class SseController {
private static final Map<String, SseEmitter> sseEmitters = new ConcurrentHashMap<>();
@GetMapping(value = "sub",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> connect(HttpServletResponse response){
String userId = UUID.randomUUID().toString();
log.info("시작 {}",userId);
SseEmitter sseEmitter = new SseEmitter(100000L * 45L);
sseEmitters.put(userId, sseEmitter);
log.info("id 값은 {}",sseEmitters.keySet());
sseEmitter.onCompletion(()->{
log.info("onCompletion sseEmitters {}",userId);
sseEmitters.remove(userId);
});
sseEmitter.onTimeout(() -> {
log.info("onTimeout sseEmitter {}",userId);
sseEmitters.remove(userId);
});
sseEmitter.onError((e) -> {
log.info("Error seeEmitter {}", userId);
sseEmitters.remove(userId);
});
try {
sseEmitter.send(SseEmitter.event().name("connect").data("Connection"));
} catch (Exception e) {
e.printStackTrace();
}
return ResponseEntity.ok(sseEmitter);
}
@GetMapping("/sleep")
public ResponseEntity<String> sleepM() throws Exception{
Thread.sleep(10000000);
return ResponseEntity.ok("Sleep");
}
@GetMapping("/answer")
public ResponseEntity<String> answer(){
return ResponseEntity.ok("answer");
}
}
테스트 시나리오
server.tomcat.max-connections=5
server.tomcat.threads.max=5
- 톰캣의 쓰레드 수를 5개로, 커넥션을 5개로 제한해보았다.
- sleep을 5번 호출 → answer 호출
- sub를 5번 호출 → answer 호출
쓰레드를 timeout 동안은 점유하고 있다는 생각이 들었다. but 테스트의 전제가 잘못되었다는 것을 깨닫고 다시 테스트를 해보았음
# 톰캣서버의 가용가능한 최대 쓰레드는 5개로 설정
server.tomcat.threads.max=5
# 기동시점부터 최소로 유지되어야 하는 쓰레드는 5개
server.tomcat.threads.min-spare=5
- sleep을 5번 호출 → answer 호출
- sub를 5번 호출 → answer 호출
어떻게 된 일일까? 쓰레드를 점유하고 있는 것이 아닌가? max-connection을 기반으로 테스트를 해보았다.
server.tomcat.max-connections=3
- sleep을 5번 호출 → answer 호출
- sub를 5번 호출 → answer 호출
테스트 결과로 볼 때 기존에 생각했던 것 과는 다른 결론이 나왔다.
- 기존의 내 생각 Thread가 반환되지 못하고 이벤트 발생을 기다릴 것이다.
- 테스트 결과
- Thread에 제한을 두었지만, 다른 요청을 받을 수 있다.
- Max-Connection에 제한을 두니, 다른 요청을 받을 수 없었다.
이를 이해하기 위해 tomcat 여러가지 자료들을 찾아보았다.
- 먼저 현재 내가 사용하고 있는 톰캣의 버전은 10버전이다.
- tomcat은 9.0버전 부터 BIO connector를 지원하지 않는다고 한다.
BIO Connector란?
- Socket Connection을 처리할 때 Java의 기본적인 IO 기술을 사용
- Thread Pool에 관리되는 thread는 소켓 연결을 받고 요청을 처리하고 요청에 대해 응답한 후 소켓 연결이 종료되면 pool에 다시 돌아오게 된다.
- 즉, connection이 닫힐 때까지 하나의 thread는 특정 connection에 계속 할당되어 있다.
- 내가 가진 궁금증은 이러한 배경을 바탕으로 발생하였던 것!
- 이러한 방식으로 Thread를 할당하여 사용할 경우, 동시에 사용되는 thread 수가 동시 접속할 수 있는 사용자의 수가 된다.
- 이러한 방식을 채택하여 사용할 경우 thread들이 충분히 사용되지 않고 idle 상태로 낭비되는 시간이 많이 발생한다.
- 이러한 문제점을 해결하고자 NIO Connector가 등장!
- 간단하게 BIO Connector는 Acceptor가 소켓을 획득하고 worker thread pool에서 socket을 처리하기 위한 idle 상태인 worker thread를 찾는다.
- 만약 Worker thread pool에 idle thread가 없다면, 요청을 처리할 thread가 없기 때문에 Acceptor는 block 된다.
- 중요한 점은 Connector 내부에서 각 요청에 상응하는 Thread를 Workde Thread Pool에서 꺼내서 1:1로 매핑해준다.
그렇다면 NIO Connector란?
- BIO와 달리 새로운 연결이 발생할 때, 바로 새로운 Thread를 할당하지 않고(Connection이 Thread와 1:1 매핑 관계가 아니다.) Poller라는 개념의 Thread에게 Connection(Channel)을 넘겨준다.
- Poller는 Socket들을 캐시로 들고 있다가 해당 Socket에서 data에 대한 처리가 가능한 순간에만 thread를 할당하는 방식을 사용해서 thread가 idle 상태로 낭비되는 시간을 줄여준다.
tomcat이 NIO 방식으로 동작하여 Thread와 1:1 매핑관계가 아니기 때문에 이러한 테스트 결과가 나왔다고 생각한다
- 앞으로 I/O MultiPlexing과 웹플럭스에 대해서도 공부할 생각이다.