전편에 이어 SSE를 코드로 구현해본 내용에 대해 정리해보았습니다.
1. 전체 구조 개요
SSE는 서버에서 클라이언트로 단방향 실시간 이벤트를 전송하는 HTTP 표준 기술입니다.
프로젝트에서는 다음 네 개의 클래스로 구조를 나누었습니다.
| 구성요소 | 역할 |
| SseEmitterManager | 연결(Emitter) 등록, 전송, 정리 담당 |
| SseEventPublisher | 이벤트 이름과 데이터 포맷 표준화 |
| SseEventController | 클라이언트 요청 수락 및 HTTP 레벨 안정화 |
| SseKeepAliveScheduler | 주기적 ping 전송으로 idle-timeout 방지 |
이 분리는 SRP를 지키고
나중에 Redis 기반 캐시나 분산 이벤트 버퍼를 추가할 때도 Manager/Publisher 레벨만 확장하면 되도록 설계했습니다.
2. SseEmitterManager
@Component
public class SseEmitterManager {
private static final long TIMEOUT_MS = Duration.ofMinutes(10).toMillis();
private final Map<String, Set<SseEmitter>> emitters = new ConcurrentHashMap<>();
public SseEmitter register(String testId, Consumer<SseEmitter> onOpen) {
SseEmitter em = new SseEmitter(TIMEOUT_MS);
emitters.computeIfAbsent(testId, k -> new CopyOnWriteArraySet<>()).add(em);
Runnable cleanup = () -> remove(testId, em);
em.onTimeout(cleanup);
em.onCompletion(cleanup);
em.onError(ex -> cleanup.run());
if (onOpen != null) onOpen.accept(em);
return em;
}
public void sendTo(String testId, String event, Object data) {
var list = emitters.getOrDefault(testId, Set.of());
for (SseEmitter em : list) {
try {
em.send(SseEmitter.event().name(event).data(data));
} catch (Exception e) {
remove(testId, em);
}
}
}
public void remove(String testId, SseEmitter em) {
var set = emitters.get(testId);
if (set != null) {
set.remove(em);
if (set.isEmpty()) emitters.remove(testId);
}
}
public Set<String> getActiveTestIds() {
return emitters.keySet();
}
}
왜 이렇게 작성했는가
- ConcurrentHashMap + CopyOnWriteArraySet 조합
- testId 단위로 충돌을 최소화하면서 여러 구독자에게 안전하게 방송(Broadcast)할 수 있습니다.
- CopyOnWriteArraySet은 추가·제거 시 복사 비용이 있지만, 읽기 빈도가 압도적으로 많기 때문에
읽기 안정성(iteration safety) 을 최우선으로 고려했습니다. - 대안으로 ConcurrentLinkedQueue를 쓸 수 있으나, 중복 제거와 삭제가 어려워 Set 구조를 선택했습니다.
- Emitter 수명 관리(onTimeout, onCompletion, onError)
- SSE는 장시간 열려 있는 연결이므로 누수방지가 중요합니다.
- 세 가지 콜백에 모두 동일한 cleanup 훅을 등록하여, 정상 종료·에러·타임아웃 어떤 경우에도 동일하게 정리되도록 했습니다.
- 전송 실패 즉시 제거
- send() 호출 중 예외가 발생하면 이미 연결이 끊긴 상태입니다.
- 즉시 제거하지 않으면 Map에 죽은 Emitter가 계속 남아 메모리와 핑 부하가 증가합니다.
- 즉시 정리는 “지연된 정리보다 안전하다”는 운영 경험에 근거합니다.
- register 시 콜백(Consumer<SseEmitter>)
- Emitter가 등록된 직후 comment/ping을 즉시 전송하기 위해 콜백을 받습니다.
- 초기 ping은 Nginx 버퍼링 해제와 503 방지(첫 바이트 플러시) 용도입니다.
3. SseEventPublisher
@Component
@RequiredArgsConstructor
public class SseEventPublisher {
private final SseEmitterManager manager;
public void ping(String testId) {
manager.sendTo(testId, "ping", Map.of("ok", true, "ts", Instant.now().toString()));
}
public void publishWebSnapshot(String testId, Object dto) {
manager.sendTo(testId, "t1_web", dto);
}
public void publishSecuritySnapshot(String testId, Object dto) {
manager.sendTo(testId, "t1_sec", dto);
}
public void publishAiResult(String testId, Object dto) {
manager.sendTo(testId, "t3_ai", dto);
}
public void done(String testId) {
manager.sendTo(testId, "done", Map.of("end", true, "ts", Instant.now().toString()));
}
}
설계 의도
- 프런트-백 간 계약(Contract) 고정
이벤트명(t1_web, t1_sec, t3_ai, done)과 페이로드 스키마를
하나의 클래스에서 통제하여, 여러 도메인에서 이벤트 이름이 중복·혼재되는 문제를 예방했습니다.
이로써 프런트는 event.name으로만 분기하면 됩니다. - SRP 분리 (Manager ↔ Publisher)
Manager는 연결 관리만, Publisher는 도메인 이벤트 관리만 맡습니다.
이 분리로 단위 테스트가 쉬워지고,
Redis 같은 외부 브로드캐스트 채널을 추가할 때도 Publisher 레벨만 확장하면 됩니다. - Ping 노출
ping은 연결 유지의 기술적 기능이지만,
동시에 프런트에서는 “연결 상태 표시(Connected)” UX에도 활용됩니다.
Publisher에서 메서드로 노출하여 다른 서비스에서도 쉽게 재사용할 수 있게 했습니다. - DTO 변환 권장 이유
JPA Entity를 그대로 전송할 경우 Lazy 로딩이나 순환 참조 문제가 발생합니다.
따라서 Controller 계층에서 DTO 변환 후 Publisher로 넘기는 것을 기본 규칙으로 삼았습니다.
4. SseEventController
이 파일에서는 HTTP 안정성을 보장하고 초기 flush를 처리합니다.
@RestController
@RequestMapping("/api/tests")
@RequiredArgsConstructor
public class SseEventController {
private final SseEmitterManager manager;
private final SseEventPublisher publisher;
@GetMapping(value = "/{testId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable UUID testId, HttpServletResponse resp) {
String id = testId.toString();
resp.setHeader("X-Accel-Buffering", "no");
resp.setHeader("Cache-Control", "no-store");
resp.setHeader("Connection", "keep-alive");
resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
SseEmitter emitter = manager.register(id, em -> {
try {
em.send(SseEmitter.event().comment("connected"));
em.send(SseEmitter.event().name("ping")
.data(Map.of("ok", true, "ts", Instant.now().toString())));
} catch (Exception ignore) {}
});
publisher.ping(id);
return emitter;
}
}
설계 의도
- X-Accel-Buffering: no
Nginx는 기본적으로 응답을 버퍼링합니다.
SSE는 실시간 스트리밍이므로 반드시 이 헤더로 버퍼링을 끄지 않으면
브라우저에 이벤트가 한꺼번에 쌓여 늦게 전달되는 문제가 생깁니다. - Cache-Control: no-store / Connection: keep-alive
캐시를 완전히 비활성화하고, 커넥션을 유지하도록 명시합니다.
이는 중간 프록시가 응답을 저장하거나 닫지 않게 하는 최소 조건입니다. - 초기 comment + ping 전송
HTTP 응답이 실제로 전송되었다는 신호를 브라우저에 즉시 보냅니다.
이렇게 해야 nginx나 ALB가 “응답이 없네?”라고 오판하여 503을 반환하는 상황을 막을 수 있습니다.
동시에 브라우저는 이 ping으로 “연결 성공” UI를 즉시 표시할 수 있습니다. - UTF-8 인코딩 명시
텍스트 이벤트 스트림은 UTF-8만 지원하지만, 일부 환경에서 깨지는 현상이 있어 명시적으로 지정했습니다.
5. SseKeepAliveSchedule
@Component
@RequiredArgsConstructor
public class SseKeepAliveScheduler {
private final SseEventPublisher publisher;
private final SseEmitterManager manager;
@Scheduled(fixedRate = 20_000)
public void keepAlive() {
for (String testId : manager.getActiveTestIds()) {
publisher.ping(testId);
}
}
}
설계 의도
- 왜 20초 간격인가
대부분의 Nginx/ALB idle-timeout은 60초 전후입니다.
절반 이하(15~30초) 주기로 핑을 보내면 안전하며,
서버 부하를 크게 늘리지 않으면서 연결 유지율을 높일 수 있습니다. - 별도 컴포넌트로 분리한 이유
스케줄러를 별도로 분리하면 환경별 핑 주기 조정이나 on/off 제어가 쉽습니다.
장애 원인 추적도 “SSE 스레드”와 “스케줄러 스레드”를 분리해 로그 단위로 분석할 수 있습니다. - 운영 팁
활성 testId가 너무 많으면 핑 폭주가 생길 수 있으므로
오래된 testId는 TTL 기반으로 정리하거나 상한을 두는 것이 좋습니다.
6. 통신 흐름 요약
Client (EventSource)
↓
Nginx (proxy_buffering off)
↓
Spring Boot Controller
↓
SseEmitterManager ↔ SseEventPublisher
↓
PostgreSQL (결과 저장)
- 클라이언트가 /api/tests/{id}/events 로 GET 요청을 보냅니다.
- 서버는 SseEmitter를 등록하고 즉시 comment/ping을 전송합니다.
- 백엔드의 각 서비스(AI, 보안, 웹 성능)가 완료될 때마다 Publisher가 이벤트를 발행합니다.
- 클라이언트는 event.name으로 분기하여 UI를 갱신합니다.
- 모든 작업이 끝나면 “done” 이벤트를 받고, 마지막으로 GET API로 최종 데이터를 복구합니다.
마무리
프로젝트에 폴링 없는 실시간 알림과 데이터 영속성을 목표로 구현해보았습니다.
핵심은 SSE는 알림만 담당만하고 데이터는 DB에 보존하도록 역할을 분리했습니다.
SSE는 WebSocket보다 단순하지만
버퍼링, 타임아웃, 끊김 복구 등 운영 환경 이슈를 세심하게 다루어야 안정적으로 동작합니다.
'프로젝트 > 웹 성능 테스트' 카테고리의 다른 글
| Nginx 도입 2편: 적용 (0) | 2025.11.09 |
|---|---|
| Nginx 도입 1편: 왜 Nginx (0) | 2025.11.06 |
| Server Sent Events(SSE): 설계 (0) | 2025.11.02 |
| upsert 적용 (0) | 2025.10.29 |
| DB 마이그레이션 (0) | 2025.10.25 |