
왜 SSE를 선택했는가
야구보구 홈 화면에서는 오늘 인증된 구장별 팬 현황을 실시간으로 보여주고 싶었다.
현재 홈 화면은 다음과 같다.

이를 위해 몇 가지 방법을 고민했었다.
Polling
클라이언트가 n초마다 서버에 “변경 있오?”라고 묻는 방식이다.
하지만 변화가 없어도 계속 요청이 날아간다. 사용자가 늘어날수록 불필요한 요청이 폭발적으로 증가하고 서버 리소스 낭비가 심해진다.
WebSocket
양방향 실시간 통신에 적합하지만 별도 프로토콜이라 서버/인프라 설정이 복잡해진다.
이 프로젝트는 “서버 → 클라이언트 알림”만 필요했기 때문에 과한 선택이었다.
SSE(Server-Sent Events)
클라이언트는 연결만 유지하면 되고, 서버는 변화가 생겼을 때 이벤트를 발행하기만 하면 된다.
HTTP 기반이라 브라우저/프록시와 호환성이 좋고, 서버가 클라이언트로 일방적으로 알림을 보낼 때 최적이다.
결론적으로, SSE를 채택했다.
SSE(Server-Sent-Events)가 무엇인가
SSE는 서버가 클라이언트로 일방향으로 이벤트를 계속 흘려보내는 방식이다.
HTTP 위에서 동작하고, 응답의 Content-Type을 text/event-stream으로 지정한 뒤 끊지 않고 계속 보낸다.
클라이언트는 연결을 한 번 열고 기다리기만 하고, 서버는 변화가 생겼을 때 이벤트를 발행한다.
SSE에서 이벤트는 빈 줄로 구분된다. 대표 필드는 event:(이름), data:(본문), id:(순번), retry:(재연결 지연)이다.
id: 101
event: connect
data: connected
id: 102
event: ping
data: ok
id: 103
event: check-in-created
data: [{"gameId":123,"homeRate":62.5,"awayRate":37.5}]
retry: 5000
하트비트(밑에서 설명)는 트래픽을 최소화하려면 주석 프레임을 쓰는 게 일반적이다. (클라이언트에 이벤트로 전달되지 않기 때문)
콜론으로 시작하는 줄은 주석으로 처리된다.
이 형식을 유지하면 안드로이드 OkHttp SSE 같은 라이브러리에서 자동으로 잘 파싱된다.
안드로이드에서 SSE (OkHttp SSE) 🆗
웹에서는 EventSource API를 쓰지만, 안드로이드에서는 OkHttp의 SSE 모듈(okhttp-sse)을 사용한다.
HTTP 위에서 text/event-stream을 오래 열어 두는 스트림을 처리하려면 지속 연결, 줄 단위 이벤트 파싱, 자동 재연결이 필요하다. OkHttp는 안드로이드 표준 네트워킹 스택에 가깝고, okhttp-sse 모듈이 이벤트 이름, id, data 콜백을 지원해서 서버가 보내는 event:/data: 포맷을 그대로 처리하기 좋다.
서버에서 SSE 제공하기 (SseEmitter) 🤙🏽
Spring MVC에서 SseEmitter은 오래 열어두는 HTTP 응답 핸들러이다. 컨트롤러가 SseEmitter를 반환하면 서버는 커넥션을 붙잡아두고 나중에 원하는 시점에 emitter.send(~)로 이벤트를 푸시할 수 있다.
onCompletion, onTimeout, onError 콜백을 통해 끊긴 연결을 정리할 수도 있다.
아래는 연결 직후 상태를 알리고, 종료/타임아웃/에러를 정리하도록 구성한 코드이다.
@RequiredArgsConstructor
@Service
public class SseEmitterService {
private static final long ONE_HOUR_TIMEOUT = 60L * 60 * 1000;
private final SseEmitterRepository repository;
public SseEmitter add() {
SseEmitter emitter = new SseEmitter(ONE_HOUR_TIMEOUT);
emitter.onCompletion(() -> repository.remove(emitter));
emitter.onTimeout(() -> { emitter.complete(); repository.remove(emitter); });
emitter.onError(t -> repository.remove(emitter));
try {
emitter.send(SseEmitter.event().name("connect").data("connected"));
} catch (Exception e) {
emitter.completeWithError(e);
}
return repository.add(emitter);
}
}
콜백을 등록하지 않으면 죽은 커넥션을 오래 붙잡아 메모리 누수가 생기기 때문에 반드시 정리해야 한다.
야구보구 SSE 전체 흐름
1. 클라이언트(안드)가 /api/event-stream에 한 번 연결을 열면 서버가 text/event-stream 응답을 열고 emitter을 등록한다.
2. 사용자가 체크인 생성을 요청하면 DB에 저장한다.
3. db에 커밋된 후 CheckInCreatedEvent를 발행한다. 커밋 이후만 브로드캐스트되도록 @TransactionalEventListner를 사용했다.
4. 리스너가 모든 SseEmitter 리스트를 순회하며 check-in-created 이벤트를 전송한다.
5. 서버는 주기적 ping 이벤트를 보내서 타임아웃 방지와 죽은 커넥션을 정리한다.
스트림 엔드포인트 만들기 🔗
SSE 엔드포인트는 반드시 text/event-stream으로 응답해야 한다. 또한 캐시나 버퍼링 때문에 실시간성이 망가지지 않도록 헤더를 명시했다.
그리고 우리는 nginx서버를 사용하기 때문에 nginx가 응답을 모아서 보내지 않도록 X-Accel-Buffering: no를 같이 넣었다.
// StreamController.java
@RequiredArgsConstructor
@RequestMapping("/api/event-stream")
@RestController
public class StreamController {
private final SseEmitterService sseEmitterService;
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter getEventStream(HttpServletResponse response) {
response.setHeader("Cache-Control", "no-cache"); // 캐시 방지
response.setHeader("Connection", "keep-alive"); // 연결 유지
response.setHeader("X-Accel-Buffering", "no"); // Nginx 버퍼링 제거
return sseEmitterService.add(); // Emitter 등록
}
}
캐시가 끼면 오래된 이벤트가 보일 수 있고, 버퍼링이 켜지면 이벤트가 쌓였다가 한꺼번에 도착해 실시간이 아니게 보이기 때문에, 이 둘을 확실히 꺼서 실시간성이 바로 반영되도록 만들었다.
트랜잭션 이벤트와 Async 📣
체크인 저장은 트랜잭션으로 처리했다. DB 커밋이 끝난 뒤에만 브로드캐스트하려고 @TransactionalEventListener를 사용했다. 일반 @EventListener를 쓰면 DB 커밋 전에 이벤트가 발행될 수 있다. 그 경우 화면에는 반영됐는데 DB에는 저장되지 않는 불일치가 발생한다. 따라서 반드시 AFTER_COMMIT 시점에만 이벤트가 발행되도록 했다.
@Async
@TransactionalEventListener
public void onCheckInCreated(final CheckInCreatedEvent event) {
List<GameWithFanRateResponse> eventData = buildCheckInEventData(event.date());
sseEmitterRepository.all().forEach(emitter -> {
try {
emitter.send(SseEmitter.event()
.name("check-in-created")
.data(eventData));
} catch (IOException e) {
System.err.println("SSE 전송 실패: " + e.getMessage());
}
});
}
(@TransactionalEventListener에 대해 더 자세한 내용은 멍구의 테코톡을 참고해주시길 바랍니다.)
브로드캐스트는 @Async로 비동기 처리했다. 커밋 직후 요청 스레드를 바로 반환해 API 응답을 빠르게 하기 위해서였다.
또한 전송 중 네트워크 지연이 발생해도 요청 스레드풀이 고갈되지 않도록 별도 스레드풀에서 처리했다. 즉, 커밋 직후 요청 스레드를 반환하고, 전송은 다른 스레드가 수행한다.
하트비트 ❤️🔥
SSE는 한 번의 HTTP 요청을 아주 오래 유지하면서 서버가 필요할 때만 한 줄씩 데이터를 흘려보내는 방식이다.
문제는 "오랫동안 아무 바이트도 흐르지 않는 시간"이 생길 때이다.
중간 장비(게이트웨이, CDN, 로드밸런서, 우리 서버 앞단의 nginx)들은 그 시간을 각자 설정된 idle 타임아웃과 비교해서 이 연결이 놀고있다고 판단하고 끊어버린다.
따라서 서버가 주기적으로 아주 작은 데이터(ping)을 흘려보내서, 중간 장비들한테 살아있다는 신호를 주는 것이다.
@Scheduled(fixedRateString = "${sse.heartbeat.interval-ms}")
public void sendHeartbeat() {
for (SseEmitter emitter : repository.all()) {
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
emitter.completeWithError(e);
}
}
}
예를 들어, 안드로이드가 /api/event-stream에 연결한 뒤 한동안 이벤트가 없다고 하자. 10초, 20초 동안 아무 것도 흐르지 않는다. Nginx 같은 중간 장비는 내부 시계를 돌리면서 “이 연결로 최근에 받은 바이트가 있나”를 감시한다. 서버가 15초마다 ping 이벤트 한 줄을 보내면 nginx는 “최근에도 바이트가 왔구나”라고 판단하고 유휴 타이머를 다시 0으로 돌린다. 만약 지하철 구간이나 네트워크 전환으로 순간적으로 통신이 끊기면 서버에서 다음 ping을 보낼 때 전송 실패 예외가 날 것이고, 우리는 그 즉시 해당 연결을 리스트에서 제거하고 정리한다. 안드로이드 쪽은 자동 재연결 로직으로 /api/event-stream에 다시 붙는다. 따라서 사용자는 끊김을 거의 감지하지 못한다. 이렇게 해서 유휴 타임아웃으로 인한 끊김을 피하고, 이미 끊긴 소켓을 오래 쥐고 있어 생기는 메모리 누수도 막는다.
'공뷰 > Spring' 카테고리의 다른 글
| [Spring] 야구보구의 실시간 기능, 배포 후 발생한 SSE 문제 정리(nginx 설정) (2) | 2025.10.05 |
|---|---|
| [Spring] Soft Delete와 영속성 컨텍스트 (flush, clear, JPA 엔티티 상태) (0) | 2025.09.29 |
| [Spring] 야구보구의 로깅 시스템 구축하기 (6) | 2025.08.28 |
| [Spring] 스프링부트 개발 환경에 따른 application.yml 환경 분리(local, dev, prod) (1) | 2025.07.27 |
| [Spring] Insomnia로 API 테스트하기, JPA 설정, 테이블 생성(Entity) (0) | 2025.01.22 |