LOL 전적 트래커에서 게임 시작/종료, 랭크 변동이 발생하면 페이지를 새로고침하지 않아도 브라우저에 실시간으로 반영됩니다. 이를 구현한 기술이 SSE(Server-Sent Events)입니다. WebSocket보다 단순하고, 서버 → 클라이언트 단방향 데이터 전송에 딱 맞습니다.
WebSocket 대신 SSE를 선택한 이유
WebSocket은 양방향 통신이 가능하지만 설정이 복잡하고 프록시/로드밸런서에서 문제가 생기는 경우가 있습니다. SSE는 일반 HTTP 연결을 유지하는 방식이라 Nginx 리버스 프록시 뒤에서도 별다른 설정 없이 동작합니다. 서버에서 클라이언트로 이벤트를 푸시하기만 하면 되는 용도에는 SSE가 더 적합합니다.
Spring Boot SseEmitter 기본 구현
@RestController
public class SseController {
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
// 클라이언트가 SSE 연결 요청
@GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃 없음
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError(e -> emitters.remove(emitter));
// 연결 즉시 초기 이벤트 전송 (연결 확인용)
try {
emitter.send(SseEmitter.event().name("connected").data("ok"));
} catch (IOException e) {
emitters.remove(emitter);
}
return emitter;
}
// 이벤트 브로드캐스트
public void broadcast(String eventName, Object data) {
List<SseEmitter> dead = new ArrayList<>();
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().name(eventName).data(data));
} catch (Exception e) {
dead.add(emitter);
}
}
emitters.removeAll(dead);
}
}
Redis Pub/Sub과 연동
수집 서버(lol-collect)와 API 서버(lol-api)가 분리되어 있어서 수집 서버에서 발생한 이벤트를 API 서버로 전달해야 합니다. Redis Pub/Sub을 중간에 두면 서버 간 의존 없이 이벤트를 전달할 수 있습니다.
// lol-collect: 이벤트 발생 시 Redis에 publish
redisTemplate.convertAndSend("lol:events", objectMapper.writeValueAsString(event));
// lol-api: Redis 메시지 수신 후 SSE 브로드캐스트
@Component
public class RedisEventSubscriber {
private final SseController sseController;
public void onMessage(String message) {
GameEvent event = objectMapper.readValue(message, GameEvent.class);
sseController.broadcast("game-event", event);
}
}
// Redis 리스너 등록
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(
new MessageListenerAdapter(redisEventSubscriber, "onMessage"),
new PatternTopic("lol:events")
);
return container;
}
프론트엔드 연결 (React)
useEffect(() => {
const eventSource = new EventSource('/api/sse/connect');
eventSource.addEventListener('game-event', (e) => {
const event = JSON.parse(e.data);
// 상태 업데이트 → 리렌더링
setEvents(prev => [event, ...prev]);
});
eventSource.onerror = () => {
eventSource.close();
// 3초 후 재연결
setTimeout(() => reconnect(), 3000);
};
return () => eventSource.close();
}, []);
Nginx 설정 주의사항
Nginx가 앞단에 있을 때 SSE가 버퍼링되어 이벤트가 즉시 전달되지 않는 문제가 생길 수 있습니다. SSE 엔드포인트에 대해 버퍼링을 끄는 설정이 필요합니다.
location /api/sse/ {
proxy_pass http://lol-api:8080;
proxy_buffering off; # 버퍼링 비활성화 (핵심)
proxy_cache off;
proxy_read_timeout 3600s; # 연결 유지 타임아웃
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
운영에서 마주친 문제들
- 연결 누수: 클라이언트가 브라우저를 닫아도 서버에서 즉시 감지하지 못할 수 있습니다. onCompletion/onTimeout 콜백과 주기적인 heartbeat 전송으로 죽은 연결을 정리해야 합니다.
- 브라우저 연결 제한: HTTP/1.1에서는 같은 도메인에 대해 브라우저가 SSE 연결을 6개로 제한합니다. 탭을 여러 개 열면 연결이 대기 상태가 됩니다. HTTP/2를 사용하면 이 제한이 없어집니다.
- heartbeat: 연결이 유휴 상태로 너무 오래 있으면 중간 프록시나 로드밸런서가 끊어버릴 수 있습니다. 30초마다 빈 이벤트를 전송해서 연결을 유지합니다.
// 30초마다 heartbeat
@Scheduled(fixedDelay = 30000)
public void sendHeartbeat() {
broadcast("heartbeat", System.currentTimeMillis());
}