채팅 도메인 ‐ SSE 도입 테크스펙 - 100-hours-a-week/2-hertz-wiki GitHub Wiki
배경 (Background)
- 프로젝트 목표 (Objective): 기존 풀링 방식의 채팅 알림 구조를 SSE 기반으로 전환하여, 시스템 부하를 줄이고 사용자에게 더 빠르고 효율적인 실시간 채팅 경험을 제공한다.
- 문제 정의 (Problem)
- 현재 "채팅방 목록" 페이지는 3초 간격의 주기적 폴링(Polling)으로 신메시지 여부를 확인하고 있음.
- 50명의 동시 사용자 기준 단 30초의 테스트에서도 평균 CPU 사용률이 52%를 초과하며, 단일 vCPU 병목 문제가 발생.
- 그 이유는 폴링 또는 반복적인 REST 요청 구조는 매번 새로운 연결을 생성하고 닫아야 하므로, 핸드셰이크 과정에서 발생하는 암호화, 인증, 포트 확보 등의 연산으로 인해 CPU 사용량이 급증.
- 전체 요청은 성공했지만, 구조적으로 확장성 부족 및 지속적인 부하 증가가 예상됨.
- 해결책
-
Long Polling
클라이언트가 서버에 요청을 보내고 이벤트가 발생할 때까지 응답을 지연시키는 방식으로, 일반적인 폴링보다 실시간성과 성능 측면에서 개선 효과가 있다.
그러나 이벤트 발생 직후 클라이언트가 즉시 재요청을 보내기 때문에, 결국 빈번한 연결 생성과 핸드셰이크가 반복되어 CPU 부하 측면에서는 기존 폴링과 유사한 한계를 가진다.
-
SSE(Server-Sent Events)
채팅방 목록 내 신메시지 알림을 SSE로 전환하면 지속 연결을 통해 불필요한 요청을 제거할 수 있어, CPU 부하를 줄이면서도 효율적인 실시간 알림 기능을 구현할 수 있다.
다만, 동시 접속자 수가 급격히 증가하는 경우에는 오히려 Polling보다 CPU 및 메모리 사용량이 높아질 수 있으므로, 적용 이후에는 성능 지표(CPU 사용률, 메모리 사용량 등)에 대한 지속적인 모니터링이 필요하다.
-
목표가 아닌 것 (Non-goals)
- WebSocket 기반 채팅 전면 전환은 포함되지 않음 (후속 리팩토링 계획 포함).
- 사용자마다 읽음 처리를 위한 캐싱 전략 도입하지 않음 (후속 리팩토링 계획 포함).
- 채팅방 UI/UX 전면 개편은 포함되지 않으며, 백엔드 메시지 송신 방식 변경만을 다룸.
설계 및 기술 문서 (Architecture & Technical Docs)
1. 공통 SSE 설정
-
기술 스택: Spring Boot (별도 라이브러리 필요 없음)
-
핵심 클래스:
SseEmitter
,MediaType.TEXT_EVENT_STREAM_VALUE
-
전역 Emitter 관리:
Map<Long, SseEmitter> emitters
(userId 기준) -
Emitter 생명주기 관리
.onCompletion()
→ 수동 종료 시 제거.onTimeout()
→ 타임아웃 발생 시 제거disconnect()
메서드 제공
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(@AuthenticationPrincipal Long userId) { return sseService.subscribe(userId); }
-
멀티플렉싱 구조기반 SSE 통신방식
- 클라이언트가
/api/sse/subscribe
로 앱 시작 시 한 번만 연결 - 특정 페이지에서만 필요한 SSE 이벤트는 클라이언트에서
listener
만 등록/해제
- 클라이언트가
2. Emitter 생성 및 초기 핑 전송
-
클라이언트와의 SSE 연결을 생성하기 위해
SseEmitter
인스턴스를 생성하며, 타임아웃 값은120,000ms
(2분)으로 설정하여 일정 시간 동안 서버로부터 이벤트가 없으면 자동 종료되도록 설정함.private static final Long TIMEOUT = 120_000L; SseEmitter emitter = new SseEmitter(TIMEOUT);
-
연결 직후, 클라이언트 측 연결 상태를 확인하고 초기 통신이 정상적으로 수립되었음을 알리기 위해 PING 이벤트를 전송함. 이는 Nginx 등 중간 프록시 타임아웃을 방지하고, 클라이언트 측 연결 감지 목적으로도 활용됨.
sendToClient(userId, SseEventName.PING.getValue(), "connect success");
-
이를 통해 서버는 최초 연결 시 클라이언트에게 즉시 응답을 보내고, 이후 heartbeat 및 실제 알림 데이터 전송을 위한 연결을 유지할 수 있음.
3. Heartbeat 주기 전략
-
SSE 연결의 안정적인 유지와 Nginx 프록시의 타임아웃 종료를 방지하기 위해, 서버는 15초 주기(
@Scheduled(fixedRate = 15000)
)로 heartbeat 이벤트를 전송.@Scheduled(fixedRate = 15000) public void sendPeriodicPings() { emitters.forEach((userId, emitter) -> { try { emitter.send(SseEmitter.event() .name(SseEventName.HEARTBEAT.getValue()) .data("heartbeat")); } catch (IllegalStateException | IOException e) { emitter.complete(); emitters.remove(userId); } }); }
-
이 설계는 Nginx의
proxy_read_timeout
기본값인 *60초보다 짧은 주기(15초)로 heartbeat를 주기적으로 전송하여, 중간 프록시나 클라이언트 측 연결이 비정상적으로 종료되는 것을 방지. -
또한 15초 간격은 너무 짧지 않으면서도 실시간성을 유지할 수 있는 적정한 수준으로, 불필요한 CPU 부하 및 GC 오버헤드를 최소화하면서도 안정적으로 연결 상태를 확인할 수 있도록 조율된 값.
4. 메시지 전송 유틸
public boolean sendToClient(Long userId, String eventName, Object data) {
...
SseEmitter emitter = emitters.get(userId);
try {
emitter.send(SseEmitter.event()
.name(eventName)
.data(data));
return true;
} catch (IllegalStateException e) {
...
} catch (IOException e) {
...
}
}
return;
}
- 전송 과정에서
IllegalStateException
이나IOException
이 발생할 수 있으며, 이는 emitter가 이미 닫혔거나 연결이 비정상적으로 종료된 경우로, emitter를 안전하게 종료하고 메모리에서 제거하여 리소스 누수 및 불필요한 재전송 방지를 처리한다. - 반환값은 전송 성공 여부를 나타내며, 상위 로직에서 후속 처리(예: 로깅, fallback 처리 등)에 활용될 수 있다.
5.1 채팅방 목록 - 새 메시지 SSE 알림
목적
- 채팅방 목록 페이지에서 실시간으로 새로운 메시지가 도착했는지 알림
API 설계
- event name:
chat-room-update
- Produces:
text/event-stream
- 권한: 인증된 사용자 (
IsAuthenticatedUser
)
처리 로직
- 사용자가 채팅 목록 페이지에 진입 시 해당 SSE 이벤트를 클라이언트에서
listener
등록 - 새 메시지가 해당 사용자의 채팅방에 도착하면 해당 사용자에게 이벤트 전송
- 메시지는 요약 정보 형태 (
chatRoomId
,lastMessage
,timestamp
)로 전달 - 클라이언트는 메시지를 수신하면 목록 UI 갱신
데이터 예시
event: chat-room-update
data: {
"channelRoomId": 1,
"partnerProfileImage": "https://example.com/profile1.jpg",
"partnerNickname": "Alice22231",
"lastMessage": "SSE 테스트14",
"lastMessageTime": "2025-05-24T23:40:40.438513",
"relationType": "SIGNAL",
"isRead": false
}
5.2 네비게이션 바 - 새 메시지 유무 SSE 알림
목적
- 사용자가 어떤 페이지에 있든 네비게이션 바에서 새로운 메시지가 도착했는지 여부를 실시간으로 표시
- 사용자는 목록/채팅방에 진입하지 않아도 메시지 도착 여부를 즉시 인지할 수 있음
API 설계
- event name:
nav-new-message
,nav-no-any-new-message
- Produces:
text/event-stream
- 권한: 인증된 사용자 (
IsAuthenticatedUser
)
처리 로직
-
상대방이 새로운 메시지를 보내고 해당 사용자가 이를 아직 읽지 않은 경우, 서버는 알림을 전송함
(단일 메시지 전송 알림 또는 '읽지 않은 메시지 있음' 플래그)
-
새 메세지를 읽었을 시에 다시 새 메제시가 없다는 알림 전송
-
클라이언트는 해당 이벤트를 수신하면 네비게이션 바 아이콘 UI를 즉시 갱신
→ 예: 빨간 점
데이터 예시
새 매세지가 있을 시 예시
event: nav-new-message
data: {}
채팅방 진입 후 읽음 처리 시 예시
event: nav-no-any-new-message
data: {}
특징 및 유의사항
- 네비게이션 SSE는 앱 전체와 연결되어 있으므로 연결 안정성 관리(끊김 감지 및 재시도)가 특히 중요
- 채팅방 진입 시
hasUnreadMessage = false
이벤트 전송하여 UI 리셋