이번 포스팅에서는 관심 키워드 뉴스가 등록되면 사용자에게 즉시 알림이 오는 기능을 구현해보겠습니다. 이 기능을 구현하기 위해서는 클라이언트와 서버 간의 실시간 통신이 가능해야 합니다. 이때 사용되는 기술이 바로 WebSocket/SSE 입니다.
실시간 통신 기술에 대해서는 예전부터 익히 들었지만 실제로 사용해본 적이 없는 기술이기 때문에 이번 프로젝트에 기능을 추가하여 직접 경험해보도록 하겠습니다.
서버에서 클라이언트로 실시간 데이터를 전송하는 기술의 양대 산맥은 WebSocket과 SSE(Server-Sent Events)입니다.
클라이언트와 서버 사이에 양방향 통신 채널을 구축하는 프로토콜 입니다. 연결이 되면 클라이언트와 서버 모두 자유롭게 데이터를 주고 받을 수 있습니다. 양방향 통신을 하기 때문에 그만큼 구현이 복잡하고 더 많은 리소스를 필요로 합니다.
오직 서버에서 클라이언트로 즉, 단방향으로 통신하는 기술입니다.
| 특징 | WebSocket | SSE (Server-Sent Events) |
|---|---|---|
| 통신 방향 | 양방향 (Bi-directional) | 단방향 (Server → Client only) |
| 주요 활용처 | 실시간 채팅, 온라인 게임, 화상 통화 | 뉴스 피드, 알림, 실시간 스포츠 점수 |
| 프로토콜 | 별도의 WebSocket 프로토콜 | HTTP 기반 |
| 호환성 | 방화벽이나 프록시 문제 가능성 있음 | HTTP 기반이라 호환성이 좋음 |
| 오버헤드 | 연결 후 적은 오버헤드 | 헤더 정보 포함으로 상대적으로 있음 |
이번 프로젝트에서는 스케줄러를 통해 뉴스가 자동으로 수집이 됩니다. 이때 사용자 본인이 등록한 키워드에 해당하는 뉴스가 수집이 되면 "새로운 뉴스가 수집되었음"을 알림으로 제공할 예정입니다. 이 기능을 통해 사용자는 더 이상 중요한 정보를 놓칠까 봐 초조해하며 페이지를 새로고침할 필요 없이, 가장 관심 있는 주제의 최신 뉴스를 누구보다 빠르게 접할 수 있게 됩니다.
"사용자가 관심 키워드로 등록한 새로운 뉴스가 시스템에 수집되는 즉시, 해당 사용자에게 실시간으로 알림을 보내준다."
즉, 현재 요구사항은 "서버가 사용자에게 알림을 보낸다"는 명확한 단방향 통신입니다. 사용자가 알림에 대해 실시간으로 서버에 응답할 필요는 없습니다. 따라서 더 가볍고, 구현이 간단하며, HTTP 표준을 그대로 사용하여 프록시나 방화벽 문제에서 더 자유로운 SSE를 채택하기로 결정했습니다. Spring MVC는 SseEmitter라는 클래스를 통해 SSE를 매우 쉽게 구현할 수 있도록 지원하므로, 별도의 라이브러리 추가도 필요 없었습니다.
실시간 알림 시스템은 크게 세 가지 요소로 구성됩니다.
1. Subscriber (구독자): 알림을 받기 위해 서버에 연결하는 클라이언트
2. Emitter (발신자): 서버에서 생성되어 각 클라이언트와의 연결을 유지하는 '파이프라인'
3. Trigger (방아쇠): 알림을 보내야 할 특정 이벤트가 발생했을 때, Emitter를 통해 메시지를 발송하는 로직
가장 먼저, 모든 SSE 연결(SseEmitter)을 관리하고 알림을 전송하는 역할을 전담할 NotificationService를 구현했습니다.
연결 관리: ConcurrentHashMap을 사용하여 userId와 SseEmitter 객체를 1:1로 매핑하여 관리합니다. 사용자가 접속을 끊거나 타임아웃이 발생하면, 이 맵에서 자동으로 해당 Emitter를 제거하여 메모리 누수를 방지합니다.
@Slf4j
@Service
public class NotificationServiceImpl implements NotificationService {
private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
@Override
public SseEmitter subscribe(String userId) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
emitter.onError(e -> emitters.remove(userId));
sendToClient(userId, "EventStream Created. [userId=" + userId + "]");
return emitter;
}
@Override
public void send(String userId, Object data) {
sendToClient(userId, data);
}
private void sendToClient(String userId, Object data) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("sse")
.data(data));
} catch (IOException e) {
emitters.remove(userId);
log.error("SSE 연결 오류 발생 [userId={}]", userId, e);
}
}
}
}
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(Authentication authentication) {
String userId = authentication.getName();
return notificationService.subscribe(userId); // 사용자를 위한 Emitter 생성 및 반환
}
구독자 조회: 새로운 뉴스가 어떤 keywordId와 관련 있는지 알고 있으므로, user_keyword_mapping 테이블을 조회하여 해당 keywordId를 구독하는 모든 userId 목록을 가져오는 매퍼 쿼리를 추가했습니다.
알림 전송: insertNews 메소드 안에서, 새로운 뉴스가 DB에 성공적으로 저장된 직후, 위 쿼리를 통해 얻은 모든 구독자에게 NotificationService를 통해 알림을 전송합니다.
List<String> subscribedUserIds = newsMapper.findUserIdsByKeywordId(keywordId);
for (String userId : subscribedUserIds) {
String notificationMessage = "관심 키워드 관련 새 뉴스가 도착했습니다: " + news.getTitle();
notificationService.send(userId, notificationMessage);
}
사용자가 웹사이트에 접속하여 알림 채널을 구독하고 있는 상태에서, 백엔드 스케줄러가 해당 사용자의 관심 키워드와 일치하는 새로운 뉴스를 수집하면, 사용자의 화면에는 페이지 새로고침 없이도 즉시 알림이 표시됩니다.
해당 로직은 curl로 연결하여 테스트를 진행하였습니다.

서버와 클라이언트 간의 실시간 통신 기능은 SSE를 활용해 비교적 간단하게 구현할 수 있었습니다. 직접 구현을 해보니 간단한 기술로 사용자 경험을 극적으로 향상시킬 수 있겠다는 생각이 들었습니다. 특히 이번 구현을 통해 비동기 스케줄러(뉴스 수집)와 실시간 이벤트(알림 전송)라는 두 가지 다른 시간적 흐름을 가진 로직을 자연스럽게 연동하는 경험을 할 수 있었습니다.
추후 기회가 된다면 양방향 통신 기술도 직접 적용하고 활용해보도록 하겠습니다.