1편에서 SseEmitter를 만들어 반환하는 기본 구조를 봤다. 하지만 실제 서비스에서는 수백, 수천 명이 동시에 접속한다. 이 연결들을 안전하게 관리하는 방법과 알림 도메인 설계를 다룬다.

왜 연결을 관리해야 하는가

SseEmitter를 컨트롤러에서 만들어 반환하면 클라이언트와 서버 사이에 연결이 하나 열린다. 그런데 이 emitter 객체를 어딘가에 보관하지 않으면, 나중에 "user1에게 알림을 보내야지"라고 했을 때 해당 emitter를 찾을 방법이 없다.

그래서 사용자 ID를 키로, SseEmitter를 값으로 저장하는 저장소가 필요하다. 이 저장소가 있어야:

  • 특정 사용자에게 알림 전송
  • 전체 사용자에게 broadcast
  • 연결 종료 시 정리
  • 현재 접속자 수 확인

이 모든 동작이 가능해진다.

graph LR A[User A] --- B(User ID: A) C[User B] --- D(User ID: B) E[User C] --- F(User ID: C) subgraph "SseEmitter Repository (ConcurrentHashMap)" B --> G[SseEmitter A] D --> H[SseEmitter B] F --> I[SseEmitter C] end J[Notification Service] -- "Send to User A" --> G

ConcurrentHashMap을 쓰는 이유

웹 애플리케이션의 멀티스레드 환경

Spring MVC는 요청 하나당 스레드 하나를 할당한다. 사용자 A가 SSE 연결을 맺는 동시에 사용자 B가 연결을 맺을 수 있고, 또 다른 스레드에서는 사용자 C에게 알림을 보내고 있을 수 있다.

이 모든 동작이 같은 emitter 저장소에 동시에 접근한다. 여기서 동시성 문제가 발생한다.

sequenceDiagram participant T1 as Thread 1
(User A Connect) participant T2 as Thread 2
(User B Connect) participant Store as Emitter Store
(ConcurrentHashMap) participant T3 as Thread 3
(Send Alert to C) Note over T1, T3: Multi-threaded Access T1->>Store: put(UserA, emitterA) T2->>Store: put(UserB, emitterB) T3->>Store: get(UserC) Store-->>T3: emitterC T3->>T3: send notification

세 가지 선택지

HashMap

스레드 안전하지 않다. 두 스레드가 동시에 put을 호출하면 내부 해시 테이블 구조가 깨질 수 있다. 최악의 경우 무한 루프에 빠지거나 데이터가 유실된다. 웹 애플리케이션에서는 절대 쓰면 안 된다.

Collections.synchronizedMap

모든 메서드에 synchronized 키워드를 건다. 안전하지만, 한 번에 하나의 스레드만 접근할 수 있다. 동시 접속이 많아지면 나머지 스레드들이 줄 서서 기다리는 병목이 생긴다.

ConcurrentHashMap

내부적으로 버킷 단위로 잠금을 건다. 서로 다른 키에 접근하는 스레드들은 동시에 작업할 수 있다. 읽기 연산은 잠금 없이 수행되므로 "현재 연결된 사용자에게 알림 보내기"처럼 읽기가 잦은 SSE 연결 관리에 가장 적합하다.

ConcurrentHashMap의 내부 구조

Java 8 이후 ConcurrentHashMap은 세그먼트 기반 잠금에서 노드 기반 잠금(CAS + synchronized)으로 바뀌었다. 해시 충돌이 심하면 Red-Black Tree로 자동 전환되어 검색 성능도 보장된다.

연결 생명주기 관리 패턴

SseEmitter를 생성하고 저장소에 넣을 때, 반드시 콜백을 등록해서 연결이 끊겼을 때 저장소에서도 제거해야 한다. 이 패턴은 거의 공식처럼 쓰인다.

핵심 흐름

  1. SseEmitter 객체를 생성한다. 타임아웃을 설정한다.
  2. 같은 사용자의 기존 연결이 있으면 complete()로 먼저 종료한다.
  3. 저장소에 새 emitter를 저장한다.
  4. onCompletion, onTimeout, onError 콜백을 등록한다.
    • 세 콜백 모두에서 저장소의 해당 엔트리를 제거한다.
  5. 초기 연결 확인 메시지를 보낸다.
  6. emitter를 반환한다.
sequenceDiagram participant C as Client participant S as Server
(Controller/Service) participant Store as Emitter Store C->>S: GET /api/subscribe S->>S: Create SseEmitter S->>Store: Check existing emitter & Complete S->>Store: Save new emitter S->>S: Register Callbacks
(onCompletion, onTimeout, onError) S->>C: Send Dummy Event (Connect Success) S-->>C: Return Emitter (Keep Connection)

왜 초기 메시지가 필요할까? 아무 데이터도 보내지 않으면 일부 프록시나 로드밸런서가 "응답이 없다"고 판단하고 연결을 끊어버릴 수 있다. 연결 직후 더미 이벤트를 하나 보내면 이 문제를 예방할 수 있다.

기존 연결 처리 전략

같은 사용자가 새 탭을 열어서 다시 연결하면 어떻게 할 것인가?

가장 단순한 전략은 기존 연결을 종료하고 새 연결로 교체하는 것이다. 한 사용자당 하나의 연결만 유지하는 방식이다. 구현이 단순하고 리소스 관리가 쉽다.

멀티 디바이스를 지원하려면 키를 userId + deviceId 같은 복합 키로 바꾸거나, Map<String, List<SseEmitter>> 구조로 한 사용자에게 여러 emitter를 매핑해야 한다. 복잡도가 올라가므로 요구사항이 명확할 때만 적용한다.

메시지 전송과 에러 처리

emitter에 send()를 호출할 때는 항상 IOException을 대비해야 한다. 클라이언트가 브라우저를 닫거나 네트워크가 끊기면, 서버 입장에서는 다음 send() 시점에야 연결이 끊긴 걸 알게 된다.

전송 실패 시에는:

  1. 저장소에서 해당 emitter를 제거한다.
  2. completeWithError()로 emitter를 에러 상태로 종료한다.
  3. 로그를 남긴다.

이 패턴을 sendToUser(), broadcast() 등 모든 전송 메서드에 일관되게 적용해야 한다.

flowchart TD Start[Trigger Notification Send] --> Send{Try emitter.send} Send -- Success --> End[Finish] Send -- IOException --> Cleanup[Error Handling] subgraph "Cleanup Process" Cleanup --> Remove[Remove from Store] Remove --> Complete[CompleteWithError] Complete --> Log[Log Error] end Log --> End

알림 도메인 설계

Entity 설계 시 고려 사항

알림 Entity를 설계할 때는 다음 정보가 필요하다:

  • 수신자 : 누구에게 보낼 것인가. userId 필드로 관리한다.
  • 유형 분류 : 시스템 알림인지, 댓글 알림인지, 좋아요인지. Enum으로 관리하면 유형별 필터링이 쉬워진다.
  • 읽음 상태 : boolean readLocalDateTime readAt. 읽음 처리 시 두 필드를 함께 갱신한다.
  • 생성 시각 : 정렬, 조회 범위, 배치 삭제 기준이 된다. @PrePersist로 자동 설정하면 실수를 방지할 수 있다.
  • 관련 링크 : 알림 클릭 시 이동할 URL. 선택적 필드다.

팩토리 메서드 패턴

Entity에 static 팩토리 메서드를 만들면 Builder를 직접 쓰는 것보다 의도가 명확해진다.

Notification.create(userId, type, title, message) 같은 시그니처는 어떤 파라미터가 필수인지 호출부만 보고 바로 알 수 있다. 링크가 포함된 알림은 createWithLink()로 분리하면 용도별 구분이 된다.

Repository 쿼리 설계

Spring Data JPA의 쿼리 메서드 네이밍 규칙을 활용하면 구현체 없이 메서드 선언만으로 쿼리가 완성된다.

  • findByUserIdOrderByCreatedAtDesc → 사용자별 최신순 조회
  • findByUserIdAndReadFalseOrderByCreatedAtDesc → 읽지 않은 알림만
  • countByUserIdAndReadFalse → 읽지 않은 알림 개수

메서드 이름이 길어지지만, 어떤 쿼리를 실행하는지 이름만으로 알 수 있다는 장점이 있다. 기간별 조회나 배치 삭제처럼 조건이 복잡하면 @Query로 JPQL을 직접 작성한다.

Enum을 DB에 저장할 때

@Enumerated(EnumType.STRING)을 사용한다. EnumType.ORDINAL은 Enum 순서가 바뀌면 기존 데이터가 깨지기 때문에 위험하다. STRING으로 저장하면 값이 문자열로 들어가서 안전하다.

자주 하는 실수

HashMap으로 SseEmitter 관리

웹 애플리케이션은 멀티스레드 환경이다. HashMap은 동시 접근에 안전하지 않다. 반드시 ConcurrentHashMap을 사용한다.

[!DANGER] 콜백 미등록

onCompletion, onTimeout, onError를 등록하지 않으면 끊어진 연결의 emitter가 Map에 계속 남는다. 이 emitter에 send()를 호출하면 IOException이 발생하고, 서버 로그에 에러가 쌓인다.

[!DANGER] 초기 메시지 누락

연결 직후 아무 이벤트도 보내지 않으면 프록시나 로드밸런서가 타임아웃으로 연결을 끊을 수 있다. 연결 확인용 더미 이벤트를 반드시 전송한다.