Entity와 연결 관리 인프라가 갖춰졌다. 이제 이것들을 엮어서 "알림을 생성하고 → DB에 저장하고 → 실시간으로 전달하는" 전체 흐름을 설계한다. 서비스 레이어, 컨트롤러 분리 전략, 비동기 전송, 프론트엔드 통합까지 알림 시스템의 아키텍처를 다룬다.
서비스 레이어의 역할
알림 시스템의 서비스 레이어는 두 가지 책임을 갖는다:
- 알림 비즈니스 로직 : 알림 생성, 조회, 읽음 처리, 삭제
- 실시간 전송 연계 : 알림을 DB에 저장한 후 SSE로 전달
핵심 흐름은 이렇다:
- 알림 객체를 생성한다.
- DB에 저장한다. 영속화가 먼저다.
- SSE를 통해 해당 사용자에게 실시간 전송한다.
DB 저장이 SSE 전송보다 먼저인 이유가 있다. SSE 전송이 실패해도 알림은 DB에 남아 있으므로, 사용자가 다음에 접속하면 API로 조회할 수 있다. 반대 순서면 SSE로 보내고 DB 저장이 실패하면 알림이 사라진다.
컨트롤러 분리 전략
(text/event-stream)"] RestCtrl["알림 REST 컨트롤러
(application/json)"] SseService[SseEmitterService] NotiService[NotificationService] end Client -- "GET /connect" --> SseCtrl SseCtrl -- "연결 관리" --> SseService Client -- "CRUD 요청" --> RestCtrl RestCtrl -- "비즈니스 로직" --> NotiService NotiService -- "알림 발생 시 연계" --> SseService SseService -- "실시간 푸시" --> Client
왜 컨트롤러를 나누는가
SSE 연결 관리와 알림 CRUD는 성격이 다르다.
- SSE 컨트롤러 : 연결 생성, 연결 종료, 연결 상태 확인. Content-Type이
text/event-stream이다. - 알림 REST 컨트롤러 : 알림 생성, 조회, 읽음 처리, 삭제. Content-Type이
application/json이다.
이 둘을 하나의 컨트롤러에 합치면 @RequestMapping의 produces 설정이 충돌한다. SSE 엔드포인트는 text/event-stream을, REST 엔드포인트는 application/json을 반환해야 하기 때문이다.
SSE 전담 컨트롤러
SSE 컨트롤러는 연결의 시작과 끝만 담당한다:
GET /api/sse/connect— SSE 연결 생성.SseEmitter를 반환한다.POST /api/sse/disconnect— 연결 수동 종료.GET /api/sse/connection-count— 현재 연결 수 조회.GET /api/sse/is-connected— 특정 사용자 연결 여부 확인.
데이터 전송은 컨트롤러가 아니라 서비스 레이어에서 일어난다. 컨트롤러는 연결만 열어주고, 실제로 이벤트를 밀어넣는 건 SseEmitterService다.
알림 REST API 컨트롤러
알림의 CRUD는 일반적인 REST 패턴을 따른다:
POST /api/notifications— 알림 생성GET /api/notifications/user/{userId}— 사용자별 알림 목록GET /api/notifications/user/{userId}/unread-count— 읽지 않은 개수PUT /api/notifications/{id}/read— 읽음 처리POST /api/notifications/broadcast— 전체 공지
Java 16 이상이면 record로 요청 DTO를 만들 수 있다. record NotificationRequest(String userId, NotificationType type, String title, String message) {}처럼 선언하면 getter, equals, hashCode가 자동 생성된다. 불변 객체라서 DTO에 적합하다.
@Async 비동기 전송
알림을 생성하는 요청의 입장에서 생각해 보자. "댓글 작성" API를 호출했는데, 댓글 저장 → 알림 생성 → 알림 DB 저장 → SSE 전송까지 전부 동기로 처리하면 응답이 느려진다.
SSE 전송은 댓글 작성의 핵심 로직이 아니다. 전송이 약간 늦어져도 사용자 경험에 큰 영향이 없다. 이런 경우 @Async로 비동기 처리하면 원래 요청의 응답 시간을 줄일 수 있다.
@Async
public void sendNotificationToUser(String userId, Notification notification) {
sseEmitterService.sendToUser(userId, "notification", notification);
}
@Async를 붙이면 이 메서드는 별도의 스레드에서 실행된다. 호출한 쪽은 바로 다음 라인으로 넘어간다.
단, @Async는 프록시 기반으로 동작하므로 같은 클래스 내부에서 호출하면 비동기가 적용되지 않는다. 반드시 다른 빈에서 호출해야 한다. 이 부분이 궁금하면 스프링 비동기 시리즈를 참고하자.
@Async 메서드 안에서 @Transactional을 쓰면 별도의 트랜잭션이 열린다. 호출한 쪽의 트랜잭션과는 독립적이다. 알림 전송 실패가 댓글 저장을 롤백시키면 안 되므로, 이 독립성이 오히려 바람직하다.
브로드캐스트 패턴
알림에는 두 가지 전송 대상이 있다:
- 개인 알림 : 특정 사용자 한 명에게. 댓글, 좋아요, 팔로우 등.
- 전체 공지 : 연결된 모든 사용자에게. 시스템 공지, 점검 안내 등.
개인 알림은 sendToUser(userId, ...)로 보내고, 전체 공지는 broadcast(...)로 보낸다.
broadcast는 저장소의 모든 emitter를 순회하면서 각각에 send()를 호출한다. 이때 특정 사용자에게 전송이 실패하더라도 나머지 사용자에게는 계속 전송해야 한다. forEach 안에서 예외를 개별 처리하는 이유다.
전체 공지의 경우 수신자가 "ALL"이므로 DB에 저장할 때 userId를 특수 값(예: "ALL")으로 넣거나, 별도의 공지 테이블을 사용하는 방법이 있다.
프론트엔드 통합 전략
SSE 기반 알림 시스템의 프론트엔드는 두 개의 통신 채널을 동시에 쓴다.
(수신 전용)"] Fetch["Fetch API
(송신/조회 전용)"] UI["알림 UI/상태 관리"] end subgraph "Backend" SseEnd[SSE 엔드포인트] RestEnd[REST API 엔드포인트] end SseEnd -- "Stream (Push)" --> ES ES -- "이벤트 발생" --> UI UI -- "읽음 처리/조회" --> Fetch Fetch -- "HTTP Request" --> RestEnd
EventSource로 수신
실시간 데이터는 EventSource를 통해 서버에서 밀어준다. 클라이언트는 이벤트 리스너만 등록하면 된다.
connect이벤트 : 연결 성공 확인. UI 상태를 "연결됨"으로 갱신.notification이벤트 : 새 알림 수신. 알림 목록에 추가하고, 브라우저 토스트 표시.announcement이벤트 : 전체 공지 수신. notification과 같은 방식으로 처리.ping이벤트 : Heartbeat. 로그만 찍고 무시해도 된다.
fetch API로 송신과 조회
클라이언트에서 서버로 데이터를 보내거나, 기존 알림을 조회할 때는 일반 fetch를 쓴다.
- 알림 목록 로드 :
GET /api/notifications/user/{userId} - 읽음 처리 :
PUT /api/notifications/{id}/read - 알림 삭제 :
DELETE /api/notifications/{id} - 테스트 알림 생성 :
POST /api/notifications
이 구조의 핵심은 SSE는 수신 전용, REST는 송신/조회 전용이라는 역할 분리다. 양쪽을 혼동하면 안 된다.
로컬 상태와 서버 상태
프론트에서 알림 목록을 배열로 관리한다. SSE로 새 알림이 오면 배열 앞에 추가하고, 읽음 처리나 삭제를 하면 로컬 배열도 함께 갱신한다.
이렇게 하면 매번 서버에 전체 목록을 요청하지 않아도 된다. 서버 API 호출 후 로컬 상태를 동기화하는 낙관적 업데이트 방식이다.
브라우저의 Notification API로 데스크톱 알림을 띄울 수 있다. 사용자가 다른 탭을 보고 있어도 알림을 인지할 수 있다. 단, Notification.requestPermission()으로 먼저 권한을 받아야 한다.
자주 하는 실수
SSE 전송은 네트워크 I/O다. 동기로 처리하면 원래 요청의 응답 시간이 늘어난다. @Async로 비동기 처리하거나, 이벤트 기반으로 분리하는 것이 좋다.
[!DANGER] broadcast에서 한 건 실패 시 전체 중단
forEach 안에서 예외를 잡지 않으면 한 사용자 전송 실패가 나머지 전체 전송을 중단시킨다. 반드시 개별 try-catch로 감싸야 한다.
[!DANGER] SSE와 REST 엔드포인트를 한 컨트롤러에 합침
produces 설정이 충돌한다. SSE는 text/event-stream, REST는 application/json이다. 반드시 컨트롤러를 분리한다.