실시간 알림 기능은 현대 웹 애플리케이션에서 거의 필수적인 요소가 되었습니다. 사용자 경험을 향상시키고, 애플리케이션의 상호작용성을 높이기 위해 진행중이던 프로젝트에도 SSE를 도입하게 되었습니다. 이 글에서는 Spring Framework와 Server-Sent Events(SSE)를 사용하여 프로젝트를 진행하며 직접 실시간 알림 기능을 구현한 방법에 대해 소개하겠습니다.
1. SSE(Server-Sent Events)란?
SSE는 웹 애플리케이션에서 서버로부터 데이터를 비동기적으로 전송받을 수 있는 기술 중 하나입니다. 이를 통해서 서버는 새로운 정보가 있을 때마다 실시간으로 클라이언트에게 데이터를 푸시할 수 있습니다. 웹 애플리케이션에서 실시간 알림기능을 구현할때 매우 유용합니다.
2. 왜 SSE를 사용하였나?
SSE 방식외에도 클라이언트가 주기적으로 서버로 요청을 보내서 데이터를 받는 Short Polling , 서버의 변경이 일어날때까지 대기하는 Long Polling 방식이 있지만, 해당 프로젝트는 실시간으로 반영되어야하고 빈번하게 발생 될 수있는 상황이기에 SSE를 선택하였습니다. 또한 서버에서 클라이언트로의 단방향 통신만 필요했기 때문에 양방향 통신이 가능한 WebSocket은 사용하지 않았습니다.
3. SSE의 흐름
1. Client -> Server 연결 요청
프론트단에서 EventSource 객체를 생성하면서 서버의 특정 URL로 HTTP GET 요청을 보내 연결 요청을 시작합니다.
백엔드는 요청을 받고 서버와 클라이언트간에 단방향 통신을 가능하게 해주는 SseEmitter 객체를 만들어 반환합니다.
2. Server -> Client 이벤트 생성 및 전송
개발자가 설정해놓은 특정 상황에 맞춰 알림 데이터를 생성하고, 자신을 구독하고 있는 클라이언트에게 비동기적으로 데이터를 전송합니다.
3-1. Emitter란?
SSE에서 Emitter는 서버 측에서 클라이언트로 이벤트를 전송하는 역할을 하는 객체 또는 컴포넌트를 말합니다. Emitter는 서버가 클라이언트에게 실시간으로 데이터를 emit(방출) 할 수 있게 해주며, 이를 통해 실시간 통신이 가능해집니다.
4. 직접 구현해보았다
NotificationController
SSE 연결 설정
// 사용자 SSE 연결 API
@GetMapping(value = "/subscribe", produces = "text/event-stream")
public ResponseEntity<SseEmitter> sseConnect(@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId,
HttpServletResponse response) {
return new ResponseEntity<>(notificationService.sseSubscribe(userDetails.getUsername(),lastEventId, response), HttpStatus.OK);
}
사용자의 연결 요청을 처리하고, SseEmitter 객체를 통해 실시간으로 데이터를 전송할 준비를 합니다.
Spring security, jwt를 사용하였기 때문에 UserDetails에서 GetUsername()으로 클라이언트 유저의 정보와 헤더값들을 가져옵니다.
@GetMapping 어노테이션과 함께 produces = "text/event-stream" 속성을 사용하여 SSE 연결을 설정해줍니다.
NotificationService
1) SSE Subscribe
@Transactional
public SseEmitter sseSubscribe(String username, String lastEventId, HttpServletResponse response) {
// 고유한 emitterId를 생성합니다. 이 ID는 사용자 이름과 시간을 포함합니다.
String emitterId = createTimeIncludeId(username);
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
// Nginx 같은 프록시 서버를 사용할 경우 불필요한 버퍼링을 방지하기 위해 헤더를 설정합니다.
response.setHeader("X-Accel-Buffering", "no");
// Emitter의 생명주기 이벤트(완료, 시간 초과, 에러)에 대한 콜백을 설정합니다.
emitter.onCompletion(() -> emitterRepository.deleteAllEmitterStartWithId(emitterId));
emitter.onTimeout(() -> emitterRepository.deleteAllEmitterStartWithId(emitterId));
emitter.onError((e) -> emitterRepository.deleteAllEmitterStartWithId(emitterId));
// 알림을 전송합니다. 이 예시에서는 단순히 구독 확인용 데이터를 전송합니다.
String eventId = createTimeIncludeId(username);
sendNotification(emitter, eventId, emitterId, new SubscribeDummyDto(username));
// 클라이언트가 놓친 데이터가 있는지 확인하고, 있다면 전송합니다.
if (hasLostData(lastEventId)) {
sendLostData(lastEventId, username, emitterId, emitter);
}
return emitter;
}
사용자가 실시간 알림을 구독하기 위해 요청하면, 서버는 SseEmitter 객체를 생성하여 클라이언트와의 연결을 유지합니다.
또한, Nginx 를 배포과정에 사용하였기 때문에 불필요한 버퍼링 방지를 위해 특정 헤더를 설정해줍니다.
2) 메시지 생성, 전송
@Transactional
public void send(User receiver, NotificationType notificationType, String content, String url) {
// 알림 객체를 생성하고 데이터베이스에 저장합니다.
Notification notification = createNotification(receiver, notificationType, content, url);
Notification saveNotification = notificationRepository.save(notification);
// 알림을 받을 사용자의 ID와 현재 시간을 기반으로 eventId를 생성합니다.
String receiverId = receiver.getEmail();
String eventId = receiverId + "_" + System.currentTimeMillis();
// 사용자 ID로 모든 SseEmitter를 조회합니다.
Map<String, SseEmitter> emitters = emitterRepository.findAllEmitterStartWithByUserId(receiverId);
// 각 SseEmitter에 알림을 전송합니다.
emitters.forEach((emitterId, emitter) -> {
emitterRepository.saveEventCache(emitterId, saveNotification);
sendNotification(emitter, eventId, emitterId, new NotificationResponseDto(
saveNotification.getId(),
saveNotification.getContent(),
saveNotification.getUrl(),
saveNotification.getNotificationType(),
saveNotification.getIsRead()));
});
}
지정된 사용자에게 알림을 전송합니다. 이 메서드는 먼저 알림을 DB에 저장하고, 해당 사용자의 모든 SSE Emitter에 알림을 전송합니다.
String receiverId = receiver.getEmail();
이 부분을 보면 제가 참여한 프로젝트에서는 Email, password를 입력해 로그인하는 방식을 사용하고 있어 Email이 고유값이었던 점, entity상의 id가 아니라 email로 receiverId를 선언하면 더 직관적인점을 이유로 String 타입으로 receiverId를 사용하였습니다.
// 해당 사용자의 모든 SSE Emitter 검색
Map<String, SseEmitter> emitters = emitterRepository.findAllEmitterStartWithByUserId(receiverId);
한개의 emitter이 아니라 해당 사용자의 모든 emitter들을 찾고 있습니다.
사용자는 개개인 한 사람이므로 한사람당 하나의 emitter가 생기는게 당연한게 아닌가? 생각이 들었지만,
pc 브라우저로 사이트에 접속하여 emitter가 생성됐을 수도 있고, 모바일로 사이트에 접속하여 emitter가 생성될 수 있으므로
한 사람당 생성될 수 있는 emitter의 개수는 하나 이상입니다.
때문에 유저당 하나의 emitter를 찾는게 아니라, 유저의 모든 emitter를 찾게 되었습니다.
3) 위 코드 안에서 분리한 메서드들
3-1) createNotification
private Notification createNotification(User receiver, NotificationType notificationType, String content, String url) {
return Notification.builder()
.receiver(receiver)
.notificationType(notificationType)
.content(content)
.url(url)
.isRead(false)
.build();
}
사용자에게 보낼 알림 객체를 생성합니다. Builder 패턴을 사용했습니다.
3-2) sendLostData
private void sendLostData(String lastEventId, String username, String emitterId, SseEmitter emitter) {
Map<String, Object> eventCaches = emitterRepository.findAllEventCacheStartWithByUserId(username); // 이벤트 캐시 조회
eventCaches.entrySet().stream()
.filter(entry -> lastEventId.compareTo(entry.getKey()) < 0) // 놓친 이벤트 필터링
.forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue())); // 놓친 이벤트 전송
}
사용자에게 누락된 알림 데이터를 전송합니다.
마지막으로 수신한 이벤트 ID 이후 모든 이벤트를 조회하여 전송합니다.
3-3) hasLostData
private boolean hasLostData(String lastEventId) {
return !lastEventId.isEmpty();
}
주어진 lastEventId가 비어있지 않은지 확인하여, 누락된 데이터가 있는지 여부를 반환합니다.
3-4) sendNotification
private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) {
try {
emitter.send(SseEmitter.event()
.id(eventId)
.name("sse")
.data(data)
);
} catch (IOException exception) {
emitterRepository.deleteById(emitterId);
throw new BaseException(BaseResponseStatus.NOTIFICATION_SEND_FAILED);
}
}
주어진 SseEmitter를 사용하여 알림을 전송합니다.
전송 중 오류가 발생하면, 해당 emitter를 삭제하고 예외처리합니다.
3-5) createTimeIncludeId
private String createTimeIncludeId(String username) {
return username + "_" + System.currentTimeMillis();
}
사용자명과 현재 시간을 결합하여 고유한 ID를 생성합니다.
이 ID는 알림이나 이벤트의 식별자로 사용될 수 있으며, 알림 데이터가 유실된 시점을 확인하는데 사용될 수도 있습니다.
4. 결과물
프론트엔드 팀원분과 같이 소통하면서 구현해보며 여러 트러블들이 많았지만, 결국 성공..! 🎉
위의 움짤은 로그인하여 접속중일때 누군가가 내 펀딩(게시글)에 후원(결제)하였을때 나에게 화면 상단에 실시간으로 토스트 알림이 나오는 장면이다.
SSE 구현자체는 프론트엔드쪽이든 백엔드쪽이든 크게 어렵지는 않았는데, 짜잘짜잘한 디테일들과 보안관련(spring security) 문제로 애를 좀 먹었다..!
📌 Reference
https://amaran-th.github.io/Spring/[Spring]%20Server-Sent%20Events(SSE)/
[Spring] Server-Sent Events(SSE)
SSE를 사용해 실시간 댓글 기능을 구현해보자.
amaran-th.github.io
https://sothoughtful.dev/posts/sse/
SSE Spring에서 구현하기 A to Z
SSE란?
sothoughtful.dev
'Backend > Spring' 카테고리의 다른 글
헥사고날 아키텍처에 대해 알아보았다. (3) | 2025.06.06 |
---|---|
Maven 설치 및 환경변수 설정 (With Intel Mac) (0) | 2025.03.05 |
카카오페이 결제취소 API 서비스에 적용하기 (Spring Boot, Vue.js) (0) | 2025.01.21 |
[스프링 핵심 원리 - 기본편] 객체 지향 설계와 스프링 (0) | 2024.03.21 |
JPA의 더티 체킹(Dirty checking)이란 무엇인가요? (0) | 2024.02.20 |