사내 위키에 정리해뒀던 Server Sent Events 기술검토 문서를 옮깁니다. 실서비스에 쓸 수 있는지 판단하려고 하나씩 확인해본 기록이라, 중간에 실패한 시도나 못 풀고 넘어간 부분도 그대로 뒀습니다.
Server Sent Events 란?
서버와 한번 연결을 맺고 서버의 데이터를 클라이언트로 보내줄 수 있는 방법이다.
웹소켓과의 차이점
소켓은 양방향이다. 서버와 클라이언트가 서로 데이터를 주고받을 수 있지만 sse는 단방향이다. 클라이언트는 서버의 데이터를 받을 수만 있다.
또한 웹소켓은 처음 연결만 http를 이용하고 이후에는 ws 프로토콜을 이용하는데, sse는 연결도 http로, 메시지 발송도 http를 이용한다!
SSE 유실 가능성?
샘플 코드들에서 보면 방어코드 로직이 존재한다. 그래서 Chat Gpt에게 이벤트 유실될 상황을 문의해봤다.
Server-Sent Events (SSE)를 사용할 때 이벤트가 유실될 수 있는 구체적인 상황은 다음과 같습니다:
- 네트워크 문제: 클라이언트와 서버 간의 네트워크 연결이 불안정한 경우, SSE 이벤트가 중간에 손실될 수 있습니다.
- 클라이언트 측 문제: 클라이언트 애플리케이션이 SSE 이벤트를 처리하는 데 문제가 있는 경우 이벤트가 유실될 수 있습니다.
- 서버 부하: 서버가 과도한 부하를 겪는 경우, SSE 이벤트를 생성하고 전송하는 데 지연이 발생할 수 있습니다.
- SSE 구현 오류: SSE 서버 또는 클라이언트의 구현 오류로 인해 이벤트가 유실될 수 있습니다.
- 브라우저 및 서버 호환성 문제: 일부 브라우저와 서버 조합에서는 SSE를 올바르게 지원하지 않을 수 있습니다.
이러한 상황을 고려하여 SSE를 구현할 때 오류 처리 및 재연결 메커니즘을 구현하는 것이 좋습니다.
말은 그럴듯한데 결국 직접 돌려봐야 아는 것들이라, 아래 테스트로 하나씩 확인했다.
연결 맺기
@RestController
@RequestMapping("/noti")
@RequiredArgsConstructor
public class SseController {
private final SseService sseService;
/**
* SSE 요청
* produces : 서버가 클라이언트에게 반환하는 데이터 타입
*/
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sseConnection(@RequestParam String username) {
SseEmitter sseEmitter = sseService.connection(username);
return sseEmitter;
}
}
커넥션 연결을 무조건 GET요청으로 해야하나?
이것도 물어봤다.
SSE 커넥션을 HTTP POST 요청으로 설정하는 것은 기본적으로 SSE의 설계 및 사용 사례와 어긋나는 방식입니다. SSE는 주로 HTTP GET 요청을 사용하여 클라이언트가 서버로부터 이벤트 스트림을 수신하는 데 사용됩니다. POST 요청을 사용하는 경우 다음과 같은 단점이 있습니다:
- 요청 및 응답 구조: 클라이언트가 서버에게 데이터를 보내는 대신 서버가 클라이언트에게 데이터를 푸시합니다. POST 요청을 사용하면 서버에서 클라이언트로 데이터를 푸시하기가 어려워집니다.
- 연결 유지: SSE는 장기적인 연결을 통해 연결을 유지하는데, POST는 요청-응답 후 연결이 종료되므로 지속적인 연결 유지가 어려워집니다.
- 불필요한 오버헤드: POST 요청은 요청 본문과 헤더 등의 오버헤드가 포함됩니다.
- 호환성: 대부분의 브라우저는 SSE를 기본적으로 지원하므로 추가 라이브러리나 설정 없이 사용할 수 있습니다.
public SseEmitter connection(String username) {
log.info("[/connect?username = " + username + "] 요청 수신!!!");
// 클라이언트의 sse 연결 요청에 응답하기 위한 SseEmitter 객체 생성
// 유효시간이 지나면 클라이언트에서 자동으로 재연결 요청함
SseEmitter emitter = emitterRepository.save(username, new SseEmitter(DEFAULT_TIMEOUT));
log.info("[" + username + "] emitter 저장!!");
emitter.onCompletion(() -> {
//sse연결이 종료될 때 호출된다.
log.info("[" + username + "] emitter.onCompletion 수행");
emitterRepository.deleteByUsername(username);
});
emitter.onTimeout(() -> {
//sse연결이 타임아웃될 때 호출된다
log.info("[" + username + "] emitter.onTimeout 수행");
emitterRepository.deleteByUsername(username);
});
emitter.onError((e) -> {
//sse연결 시 에러 발생할 때 호출된다
log.info("[" + username + "] emitter.onError 수행");
emitterRepository.deleteByUsername(username);
});
// 연결 직후, 데이터 전송이 없을 시 503 에러 발생. 무조건 더미데이터를 보내주어야한다.
sendToClient(emitter, username, "연결되었습니다. " + username + "님");
return emitter;
}
여기서 하나 걸리는 게 있는데, 연결 직후 아무 데이터도 안 보내면 503이 난다. 그래서 더미 메시지를 무조건 한 번 보내줘야 한다.
✅ 테스트 1, 2 — 오래 붙여놔도 괜찮나
24시간 간격으로 커넥션이 다시 연결되어야 하고, 1시간 간격으로 발송하는 메시지가 잘 발송/수신되어야 함.
→ 3일 동안 잘 동작함
15일 17시 최초 연결
1시간 간격으로 메시지 발송
16일 17시 (15일 17시 최초연결 후 24시간 경과)
AsyncRequestTimeoutException 발생 후 재연결
17일 17시 (16일 17시 연결 후 24시간 경과)
AsyncRequestTimeoutException 발생 후 재연결
타임아웃이 나면 AsyncRequestTimeoutException이 찍히는데, 에러처럼 보이지만 그 뒤에 클라이언트가 알아서 다시 붙는다.
✅ 테스트 3 — 발송하는 JSON 데이터가 엄~청 크면 혹시 잘리진 않나?
→ json 10만 줄, 1.65MB 성공
상품 2만 개를 실어서 보내봤는데 잘리지 않았다.
✅ 테스트 4 — 커넥션 연결 후 브라우저가 종료되면 서버에서 종료된 걸 어떻게 알아채는가?
→ 브라우저 종료, 프론트에서 close 이벤트를 호출해도 서버에서 알아채지 못한다.
→ 서버에서 타임아웃 발생 시 기존 emitter를 제거해주는 로직을 추가해줘야 한다.
timeout 5분으로 설정한 상태에서 coco, honny, larry 세 명이 연결된 상태였다. 여기서 coco 브라우저를 종료한 뒤 등록된 emitter들을 조회하면 coco는 그대로 존재한다.
서버에는 coco가 종료되었는지 알 수 없음.!!
프론트에서 이렇게 close를 호출해줘도 마찬가지다.
const disconnect = () => {
console.log("disconnect!!");
console.log(evtSource);
evtSource.close();
}
disconnect 버튼을 누르면 클라이언트 쪽에서는 끊겼지만, 서버는 어떠한 신호도 못 받은 상태여서 coco가 그대로 남아있다. (버그라고 주장하는 의견도 있는 듯)
실제로 위 상태에서 메시지를 발송하면 에러가 발생한다.
timeout으로 설정한 5분이 지나야 정리된다.
//타임아웃 발생
[honny] emitter.onTimeout 수행
[coco] emitter.onTimeout 수행
[larry] emitter.onTimeout 수행
//타임아웃된 기존 연결 종료
[honny] emitter.onCompletion 수행
[larry] emitter.onCompletion 수행
[coco] emitter.onCompletion 수행
timeout 시간이 지난 후에야 coco가 사라졌다.

✅ 테스트 5 — 게이트웨이 붙였을 때 잘 동작하나??
→ Yes
🔥 테스트 6 — PUSH 서버 1대, 클라이언트 10000대가 동시에 커넥션 연결 후 메시지를 받아야 한다면?
JMeter로 테스트 시도… 실패!
How to Load Test SSE Services with JMeter 이 게시글을 따라해보았지만 잘 동작하지 않아 실패.
HttpRequest로 수행해보니 connection이 종료될 때까지 결과를 볼 수 없어 실패.
테스트용 서버 실행하여 시도
그래서 클라이언트 역할을 하는 서버를 직접 만들었다. 스레드 5000개를 띄워서 각각 SSE에 붙게 했다.
for (int i = 0; i < 5000; i++) {
Thread thread = new MyThread("Thread-" + i);
thread.start();
}
connection.setRequestMethod("GET");
connection.setConnectTimeout(5000);
connection.setReadTimeout(0); // 무한 대기.
connection.connect();
String line;
while ((line = reader.readLine()) != null) {
...
}
끊기면 5초 후에 다시 붙게 해뒀다.
Thread.sleep(5000); // 5초 후에 다시 연결 시도
PUSH 서버를 실행한 후 Client 서버를 실행하면 SSE 구독이 시작된다. PUSH 서버는 클라이언트와 연결할 때 스레드 수가 올라갔다가 내려간다.
/noti/send-random 으로 메시지를 랜덤하게 보낸다. 이때 Thread Dump를 찍어보면 http-nio-8080-exec-82 스레드에서 send-random을 처리 중이다.
갑자기 컴퓨터가 멈칫하는 순간 다시 스레드가 올라간다.
근데 궁금한점
스프링에서 띄울 수 있는 스레드 개수는 200개까지 아닌가???
✅ 테스트 7 — 동일한 username으로 탭을 여러개 켜놓고 연결하면?
→ username에 등록된 모든 sse연결에 메시지 발송 가능
emitter를 하나만 들고 있으면 나중 연결이 앞 연결을 덮어쓰기 때문에, 저장소를 Map<String, List<SseEmitter>> 로 바꿨다.
@Repository
public class SseEmitterRepository {
private final Map<String, List<SseEmitter>> emitterList = new ConcurrentHashMap<>();
public SseEmitter save(String id, SseEmitter sseEmitter) {
boolean empty = CollectionUtils.isEmpty(emitterList.get(id));
if (empty) {
emitterList.put(id, new ArrayList<>());
}
emitterList.get(id).add(sseEmitter);
return sseEmitter;
}
public List<SseEmitter> findAllByUsername(String username) {
return emitterList.get(username);
}
public void deleteAllByUsername(String username) {
emitterList.remove(username);
}
}
public void send(String username, Map<String, Object> content) {
List<SseEmitter> allByUsername = emitterRepository.findAllByUsername(username);
for (SseEmitter sseEmitter : allByUsername) {
sendToClient(sseEmitter, username, content);
}
}
✅ 테스트 8 — 브라우저 or 탭 여러개가 연결하면?
이게 제일 의외였다.
동일한 username으로 연결할 때, 6번째까지 연결되고 7번째는 펜딩된다.
7번째 탭은 펜딩 상태에서 멈춘다! 1~6번째 탭에서 타임아웃 후 재연결 시 랜덤하게 서버와 연결된다. (1~5번은 연결되고, 6번째는 펜딩되고, 7번째가 연결될 수도 있음)
서로 다른 username으로 연결할 때도 마찬가지로 6번째까지 연결되고 7번째부터는 펜딩된다.
서버 문제가 아니라 브라우저가 같은 도메인에 대해 동시 연결 수를 제한하기 때문이다.

서버 로그만 보면 원인을 못 찾는다. 요청 자체가 서버까지 오지 않기 때문이다.