해당 글은 단일 서버 환경 기준입니다. 다중 서버 환경에서의 몇 가지 개선점은 [결론]에 짧게 정리했습니다.
특정 키워드에 관심 있는 사용자가 해당 키워드가 포함된 게시물이 등록되었을 때, 실시간으로 알림을 받을 수 있도록 `SSE(Server-Sent Events)와 Redis`를 활용하여 실시간 알림 시스템을 구축했다.
1. SSE + Redis 조합 선택 이유
실시간 알림 시스템을 구현할 때 고려했던 대표적인 기술로는 WebSocket, Firebase Cloud Messaging(FCM), Server-Sent Events(SSE) 등이 있다.
이 중에서 SSE + Redis 조합을 선택한 이유는 다음과 같다.
SSE + Redis 조합 선택 이유
처음에는 양방향 통신이 가능한 WebSocket을 고려했으나, 이 기능에서 필요한 통신은 서버 → 클라이언트 단방향 푸시뿐이었다.
양방향 채널이 굳이 필요하지 않은 상황에서 WebSocket을 도입하면 다음과 같은 비용이 더 든다.
- 표준 HTTP가 아닌 별도의 프로토콜 업그레이드(핸드셰이크) 과정이 필요
- 프록시/로드밸런서/인증 미들웨어와의 호환성 고려 사항이 늘어남
- 서버 측에서 양방향 메시지와 연결 상태를 다루는 코드가 추가됨
반면 SSE는 단방향 푸시 용도에 맞게 설계되어 있고, 다음과 같은 장점이 있다.
- 표준 HTTP 위에서 동작하므로 기존 인프라(프록시, 로드밸런서, 인증 필터)와 자연스럽게 어울림
- 브라우저의 EventSource API가 자동 재연결을 기본 제공
- 서버 구현이 단순(헤더 설정 + 텍스트 스트림 전송)
따라서 "서버에서 클라이언트로의 단방향 알림"이라는 요구에 SSE가 더 적합하다고 판단했다.
Redis를 활용한 키워드 저장
사용자별 관심 키워드를 빠르게 저장 및 조회하기 위해 Redis의 SET 자료구조를 사용했다.
사용자 단위로 키를 분리하여 키워드 추가/삭제/조회를 모두 SET 연산으로 처리한다. SET은 사용자별 키워드 컬렉션을 관리하는 저장소 역할을 한다.
2. 알림 발생 흐름
1️⃣ 사용자가 특정 키워드를 등록하면, `Redis`의 `Set` 자료구조에 저장 (keywords:{userId})
2️⃣ 새로운 게시글이 생성되면, Redis에서 등록된 모든 사용자 키워드를 조회
3️⃣ 게시글 제목에 포함된 키워드를 가진 사용자에게 알림을 DB에 저장
4️⃣ 동시에 SSE(Server-Sent Events)를 통해 클라이언트에 즉시 알림 전송
여기서 Redis는 메시지 브로커로 사용되지 않고, 키워드 저장소 역할만 한다. 알림은 SSE를 통해 애플리케이션 서버에서 클라이언트로 직접 푸시한다.
3. SSE + Redis를 활용한 실시간 알림 시스템 구현
SSE 연결 관리
클라이언트가 `/notifications/stream` 엔드포인트를 호출하면 사용자별 `SseEmitter`를 생성해 메모리에 보관한다.
30분 동안 연결을 유지하고, 완료/타임아웃 시 맵에서 정리한다.
▶ NotificationSseEmitters.java
@Component
public class NotificationSseEmitters {
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter createForUser(Long userId) {
SseEmitter emitter = new SseEmitter(1800000L); // 30분 동안 연결 유지
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
// 연결 직후 테스트 이벤트 전송
try {
emitter.send(SseEmitter.event().name("connect").data("Connected successfully"));
} catch (IOException e) {
emitters.remove(userId);
}
return emitter;
}
public void sendToUser(Long userId, NotificationDTO notification) {
SseEmitter emitter = this.emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event().name("notification").data(notification));
} catch (IOException e) {
this.emitters.remove(userId);
}
}
}
}
emitters 맵은 ConcurrentHashMap으로 동시성 안전성을 확보했다. 한 사용자가 여러 탭에서 접속하는 경우 마지막 연결만 보관되는 구조이며, 사용자당 한 개의 활성 연결을 가정했다.
키워드 등록
사용자가 키워드를 등록하면 Redis에 `keywords:{userId}` 형식의 SET으로 저장한다.
일정 기간(7일) 후 자동 만료되도록 TTL을 설정해, 더 이상 활동하지 않는 사용자의 키워드가 무한정 누적되지 않도록 했다.
▶ NotificationService.java
@Transactional(readOnly = true)
public ResponseEntity<String> addKeyword(String keyword) {
User user = userService.getAuthenticatedUser()
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
String key = "keywords:" + user.getId();
redisTemplate.opsForSet().add(key, keyword);
redisTemplate.expire(key, 7, TimeUnit.DAYS);
return ResponseEntity.ok("키워드 등록 완료");
}
@Transactional이 관리하는 트랜잭션은 JPA가 사용하는 DB 커넥션에 한정된다. RedisTemplate 호출은 같은 메서드 안에 있더라도 별도의 Redis 커넥션으로 즉시 전송되며, JPA 트랜잭션이나 readOnly의 영향을 받지 않는다.
따라서 위에서 readOnly = true는 인증 사용자 조회(JPA) 부분에만 의미가 있고, Redis 쓰기 동작(opsForSet().add, expire)과는 무관하다. (코드의 의미 명확성을 위해서는 추후 어노테이션을 분리하는 것이 더 바람직하다.)
새 게시글이 등록 시 키워드 매칭 및 알림 발송
게시글이 작성되면 Redis에 등록된 모든 키워드를 조회해 제목과 매칭하고, 일치하는 사용자에게 알림을 저장하고 전송한다.
▶ NotificationService.java
@Transactional
public void createKeywordsNotification(Post post) {
Set<String> keys = redisTemplate.keys("keywords:*");
try {
for (String key : keys) {
Long userId = Long.parseLong(key.split(":")[1]);
if (userId.equals(post.getUser().getId())) continue; // 본인 게시글 제외
Set<String> keywords = redisTemplate.opsForSet().members(key);
Set<String> matchedKeywords = new HashSet<>();
for (String keyword : keywords) {
if (post.getTitle().contains(keyword)) {
matchedKeywords.add(keyword);
}
}
if (!matchedKeywords.isEmpty()) {
Optional<User> user = userRepository.findById(userId);
if (user.isPresent()) {
String message = "[키워드 알림] " + post.getTitle();
Notification notification = Notification.builder()
.keyword(String.join(", ", matchedKeywords))
.message(message)
.isRead(false)
.type("keyword")
.user(user.get())
.post(post)
.build();
notificationRepository.save(notification);
sseEmitters.sendToUser(userId, convertToDTO(notification));
}
}
}
} catch (NullPointerException e) {
throw new KeywordNotFoundException("등록된 키워드가 없습니다.");
}
}
흐름은 다음과 같다.
1️⃣ `redisTemplate.keys("keywords:*")` 로 키워드를 등록한 모든 사용자의 키를 조회
2️⃣ 키별로 SET 멤버를 꺼내, 게시글 제목에 포함되는지 `String.contains()` 로 확인
3️⃣ 매칭된 키워드가 있다면 Notification 엔티티를 저장하고 `SseEmitter` 로 즉시 발송
위 구현으로, 키워드를 등록한 사용자에게 해당 키워드가 포함된 새 게시물이 올라올 때 실시간 알림이 전달된다.

4. 결론
단일 서버 환경에서, SSE 기반 단방향 푸시와 Redis SET 기반 키워드 저장의 조합은 빠르게 만들고 충분히 안정적으로 운영할 수 있는 선택이었다.
다만 글에서 다룬 구현은 다음과 같은 명확한 전제와 한계 위에서 동작한다.
- SSE 연결 정보가 단일 JVM의 메모리(ConcurrentHashMap) 에 저장된다.
- 키워드 매칭은 선형 순회이며, KEYS 명령에 의존한다.
- 알림 발송이 트랜잭션 커밋 전에 일어난다.
다중 서버, 대규모 트래픽 환경으로 확장한다면 다음과 같은 개선이 필요하다.
- SSE 연결 공유를 위한 Redis Pub/Sub 또는 메시지 브로커 도입
- 키워드 → 사용자 역인덱스 구조로 전환해 전체 순회 자체를 제거
- 알림 발송을 커밋 후 이벤트로 분리해 일관성 확보
'Projects' 카테고리의 다른 글
| [Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어 (0) | 2025.02.26 |
|---|---|
| [InConcert] LazyInitializationException 해결 (0) | 2025.01.19 |
| [InConcert] 스크래핑 비동기 처리(2) (0) | 2025.01.15 |
| [InConcert] 스크래핑 비동기 처리(1) (0) | 2025.01.10 |