안녕하세요.
최근 진행하고 있는 사이드 프로젝트에서 클라이언트측의 기획 요구사항으로 실시간 알림 기능이 개발이 되어야 한다고 요청을 받았습니다.
백엔드 진영에서는 해당 요구사항을 위한 기술로 SSE, FCM, Web-Socket 3가지 기술이 언급이 되었고,
이 중에서 SSE를 선택하게 되었고 개발을 진행하면서 해당 기술에 대한 학습도 병행하여 정리해보려 합니다.
우리 팀은 왜 SSE를 선택하였는지.
기술 선택에 앞서 고민했던 포인트는 다양하게 있었습니다. 그 중에서 SSE를 선택하게 된 주요 이유로는 아래와 같아요.
첫번째로 MVP 성향의 프로젝트로 단기간 개발이 필요했습니다.
전체 프로젝트 기간이 한달도 채 안남은 시점에서 받은 요구사항이였고, 1차 MVP 적용을 코앞에 앞두고 있는 시점에서 제일 빠르게 적용할 수 있는 기술이 SSE라고 생각했습니다.
두번째는 기획 요구사항입니다.
실시간이라는 요소는 중요했지만 백그라운드 푸시 알림이 필요없었고, 서비스를 사용중인 유저를 대상으로 알림 카운트를 실시간으로 보여주기만 할 뿐이였기 때문에 SSE로 충분하다고 판단했어요.

저희 팀은 두가지 이유를 주요 기준으로 SSE를 선택하여 개발을 진행했고, 현재도 기획과 개선사항들을 주고받으며 발전중에 있습니다.
다음은 SSE 적용을 위해 궁금했던 부분들에 대한 학습 정리인데요.
SSE 또한 HTTP 프로토콜을 기반으로 하는 기술인데, 실시간 응답이 가능한 이유가 갑자기 궁금해져 간단하게 정리해보았습니다.
SSE 개요
SSE는 서버에서 클라이언트로 실시간 데이터를 단방향으로 전송하는 HTML5 표준 기술입니다.
HTTP 프로토콜을 기반으로 하며,
서버가 클라이언트에게 지속적으로 이벤트를 푸시할 수 있게 해줍니다.
HTTP와 다르게 실시간 응답이 가능한 이유
일반적인 HTTP 통신과 SSE 모두 Keep-Alive 방식을 사용하여 TCP 연결을 유지합니다. (HTTP1.1 기준 Default)

처음에는 Keep-Alive 헤더 설정에 의한 차이로 실시간 응답이 가능한 줄 알았으나,
두 방식의 핵심 차이는 응답 완료 여부입니다.
일반 HTTP의 경우에는 Content-Length를 사용하거나, Transfer-Encoding: chunked 사용 시 0 크기 청크를 전송하여 응답이 완료되었음을 알립니다. (Spring에서 return 구문을 통한 응답 시 자동 설정)
SSE는 Transfer-Encoding: chunked를 사용하되, 0 청크를 보내지 않아 클라이언트가 계속 다음 데이터를 기다리도록 합니다.
또한 Content-Type의 차이도 있습니다.
SSE는 일반 HTTP처럼 JSON을 받는 게 아니라, text/event-stream이라는 전용 MIME 타입을 사용해야 합니다.
브라우저는 이 타입에 대한 응답에 대해서만 EventSource 전용 로직을 사용할 수 있는데요.
EventSource API에서는 자동 재연결 및 이벤트 스트림 데이터 파서 등 실시간 응답을 처리할 수 있는 로직들이 함축되어 있어요.
이외에도 중요한 차이점이 있는데요.
SSE의 경우에는 UTF-8로 인코딩 된 텍스트 데이터만 응답할 수 있어요.
SSE 프로토콜 자체가 텍스트 기반으로 설계가 되었기 때문인데,
이 때문에 이미지 파일과 같은 바이너리 데이터는 응답을 못한다는 단점이 있어서 요구사항에 맞게 사용하는게 중요해요.
기본적인 동작 과정
기본적인 모식도는 아래와 같아요.

간단한 동작은 아래 세 단계로 나누어 볼 수 있어요.
- 클라이언트에서 최초 연결 요청
- 서버에서 요청에 대한 응답 전송
- 서버에서 발생된 이벤트에 대한 실시간 응답 (연결이 종료될 때까지)
SSE 통신을 위해 서버에서는 아래 로직 구성이 필요해요.
- 클라이언트 연결을 위한 엔드포인트
- 클라이언트 별 상태 관리(SseEmitter)
- 이벤트 전송 로직
Springboot에서는 다행이 SSE 통신을 위한 기능들을 프레임워크단에서 제공하고 있기 때문에 별도의 의존성이 필요하지 않고, 쉽게쉽게 구현이 가능해요. 도입 부분에서 설명드린 SSE 기술 선택의 가장 큰 이유이기도 합니다.
클라이언트 연결을 위한 엔드포인트
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final SseEmitterService sseEmitterService;
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(
@AuthenticationPrincipal CustomUserDetails user) {
// SseEmitter 생성 및 등록
SseEmitter emitter = sseEmitterService.createEmitter(user.getId());
return emitter;
}
}
저희 서비스의 경우에는 Spring Security 필터를 통해 커스텀한 UserDeatils 객체를 사용중이여서 매개변수로 해당 객체를 받고 있어요.
추가로 클라이언트 식별자로는 userId값을 사용하고 있기 때문에 createEmitter 매개변수로 넘겨주고 있어요.
각 서비스에 맞게 변경하여 사용하면 되기 때문에 참고만 해주세요.
클라이언트 상태 관리를 위한 SseEmitter 클래스
HTTP는 본래 무상태(Stateless) 프로토콜이지만,
SSE는 하나의 요청에 대해 여러 응답을 전송하기 위해 연결 상태를 유지해야해요.
Spring에서는 SseEmitter 객체를 통해 각 클라이언트와의 연결을 관리하고, 필요한 시점에 데이터를 전송할 수 있어요.
@Slf4j
@Service
public class SseEmitterService {
// 클라이언트별 Emitter 관리 (단일 서버라 메모리로 관리)
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private static final Long TIMEOUT = 60 * 1000 * 60L; // 60분
/**
* 클라이언트 생성 및 등록
*/
public SseEmitter createEmitter(Long userId) {
SseEmitter emitter = new SseEmitter(TIMEOUT);
// 클라이언트 등록
emitters.put(userId, emitter);
// 연결 종료 시 제거
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
emitter.onError((e) -> emitters.remove(userId));
try {
emitter.send(SseEmitter.event()
.name("connect")
.data("Connected"));
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
}
/**
* 특정 사용자에게 알림 전송
*/
public void sendNotification(Long userId, NotificationResponse notification) {
SseEmitter emitter = emitters.get(userId);
log.debug("Connected emitter Info -> {} ", emitter);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.name("notification")
.data(notification));
} catch (IOException e) {
emitters.remove(userId);
}
}
}
}
먼저 createEmitter의 경우 연결 요청을 한 클라이언트의 상태를 관리하기 위한 메서드에요.
저희 서비스는 단일 서버로 구성이 되고 있고,
확장 가능성이 없다고 판단되어 간단히 메모리로 관리하고 있어요.
(톰캣 멀티쓰레드 환경이기에 ConcurrentHashMap 자료구조로 관리)
주의해야할 점은 emitter.send를 통해 "Connected"라는 데이터를 연결 요청 시 응답으로 내리고 있는 부분이에요.
로컬에서 테스트할 경우에는 응답을 내리지 않아도 괜찮았지만, 배포 시점에서는 503에러가 발생할 수 있어요.
이유로는 저희 서비스를 비롯한 대부분의 배포 환경에서는 Nginx와 같은 리버스 프록시가 붙어있을건데, 프록시 입장에서는 아무런 응답이 오지 않는다면 타임아웃으로 간주해버리는 구조적 문제때문이라고 볼 수 있어요.
사실 더미 데이터를 보내고나서도 응답이 Nginx timeout 시간동안 발생하지 않는다면 동일한 문제가 발생하겠지만,
하트비트 로직을 추가하는걸로 방지가 가능해요.
(클라이언트 측 연결 확인 여부를 위한 응답을 위해서라도 최초 연결 시에 응답을 추가하는 것은 좋아 보여요.)
이벤트 전송 로직
sendNotification 부분은 이벤트 전송이 필요한 도메인에서 사용하는 이벤트 전송 메서드로,
클라이언트 식별자(userId)를 매개변수로 받고 있어요.
대상 클라이언트를 메모리에서 찾고, 해당 클라이언트와 연결된 Body에 응답에 필요한 데이터를 내려주는 로직으로 간단히 구성되어 있어요.
Nginx 환경에서의 주의할 점
저희 서비스에서는 리버스 프록시를 위해 Nginx를 사용중인데, 로컬에서 분명 연결 및 응답 테스트를 확인한후에 PR을 올리고 공유를 드렸는데 프론트분께서 연결이 안된다는 연락이 왔었어요.

감사하게도 해당 문제 분석을 해주시고 Nginx 설정 문제일거라는 힌트도 같이 주셔서 금방 조치를 취할 수 있었습니다 ㅎㅎ...
SSE 연결을 위해서는 아래와 같이 Nginx 설정을 추가로 해주어야 하는데요.
location / {
proxy_pass http://localhost:8080;
# SSE 설정
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
chunked_transfer_encoding on; //default : on
}
크게는 아래와 같은 이유가 있어요.
- Nginx 버퍼링 : SSE는 실시간으로 응답을 내려야 하는데, Nginx 버퍼링 설정이 되어있으면 전달이 되지 않아요.
- HTTP 버전 : http 1.0의 경우 Keep-Alive를 지원하지 않기 때문에 명시적으로 1.1 설정을 해주어야해요.
- Nginx 읽기 타임아웃 : 사실상 이 부분은 서버측 하트비트가 적용이 되어 있다면, 하트비트보다 좀 더 여유롭게 잡으면 돼요.
위에는 전역 처리를 해주고 있는데, 혹시 모를 문제를 위해 SSE 전용 location으로 변경이 필요해요.
SSE 고도화 포인트
현재는 기본적인 SSE 기술을 도입하여 실시간 알림 기능을 적용했는데요.
아래와 같은 고도화 해야할 개선 과제들을 적용해보는것도 괜찮을 것 같아서 작성해두려해요.
- SseEmitter 객체 관리 : 멀티 서버가 된다면 정합성 문제가 발생할 수 있기에 Redis같은 별도의 관리 Tool 도입이 필요해 보여요.
- 메시지 손실 대응 : 재연결과 같은 경우에 로스된 메시지들을 전송할 수 있는 로직 검토가 필요할 것 같아요.
이 부분들 외에도 고려해볼만한 사항들은 많을 것 같은데요. SSE 트러블 슈팅을 천천히 경험하면서 찾아보려해요.
마무리
빠른 개발 속도와 비교적 크지 않은 요구사항이라는 두 가지 조건을 고려해, 이번에는 SSE 기반의 실시간 알림 기능을 도입하게 되었습니다.
다만 이후 채팅 기능에 대한 확장성 논의가 이어지면서, 채팅이 실제로 도입된다면 실시간 통신을 WebSocket으로 통합하는 방향도 충분히 고려 대상이 될 수 있을 것 같습니다. 이 경우 현재 구조에서의 전환은 적지 않은 개발 공수가 추가될 것으로도 예상됩니다.
이러한 경험을 통해, MVP 성향의 프로젝트에서는 현재 요구사항을 충족하는 단순한 기술 선택도 중요하지만, 향후 변경 가능성을 얼마나 감내할 수 있는지가 기술 선택의 핵심 요소가 아닐까라는 고민을 하게 되었습니다.
이런 고민을 남기며 이번 글을 마무리하겠습니다.
읽어주셔서 감사합니다.
레퍼런스
'Develop > Project' 카테고리의 다른 글
| 카카오페이 단건 결제 API 매핑 이슈 (0) | 2025.02.25 |
|---|---|
| 채팅을 WebRTC로 하라고? (0) | 2024.08.05 |
| [프로젝트 회고] 나의 첫 팀 프로젝트 - CodeArena (0) | 2024.07.17 |
개발 기술 블로그, Dev
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!