[InConcert] SSE+Redis를 활용한 실시간 알림

2025. 1. 22. 15:50·Projects
해당 글은 단일 서버 환경 기준입니다. 다중 서버 환경에서의 몇 가지 개선점은 [결론]에 짧게 정리했습니다.

 

특정 키워드에 관심 있는 사용자가 해당 키워드가 포함된 게시물이 등록되었을 때, 실시간으로 알림을 받을 수 있도록 `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
'Projects' 카테고리의 다른 글
  • [Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어
  • [InConcert] LazyInitializationException 해결
  • [InConcert] 스크래핑 비동기 처리(2)
  • [InConcert] 스크래핑 비동기 처리(1)
oniwon
oniwon
  • oniwon
    눙눙의 개발일지
    oniwon
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Spring
      • CS
      • Infra
      • Projects
      • F-Lab
      • Airflow
  • 인기 글

  • 블로그 메뉴

    • 홈
    • 개인정보처리방침 (Privacy Policy)
    • 블로그 소개 (About)
  • 최근 글

  • 태그

    redis
    F-Lab
    Flab커넥터스
    inconcert
    Flab멘토링
    SSE
    에프랩멘토링
    자바 다형성
    네트워크
    Flab추천인코드
    FLAB
    tls
    Java
    에프랩
    Flab 추천인코드
    동시성
    JVM
    커넥터스2385
    자바백엔드
    자바면접
    DAG
    https
    Flab 3주차 후기
    Flab 멘토링
    dns
    Flab후기
    자바 내부 동작
    Airflow
    비동기 처리
    병렬 처리
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
oniwon
[InConcert] SSE+Redis를 활용한 실시간 알림
상단으로

티스토리툴바