지난 편에서 SSE 연결을 관리하는 SseEmitterService를 만들었다. 이제 그 위에 알림 비즈니스 로직, REST API, SSE 전담 컨트롤러, 프론트엔드를 쌓아서 전체 시스템을 통합한다. 이번 편을 마치면 브라우저에서 SSE로 실시간 알림을 받고, 테스트 알림도 보낼 수 있다.

이번 편에서 만드는 것

통합할 컴포넌트는 네 가지다:

  • NotificationService : 알림 생성 → DB 저장 → SSE 전송을 하나의 흐름으로 묶는 서비스 레이어
  • SseController : SSE 연결/해제만 전담하는 컨트롤러
  • NotificationController : 알림 CRUD를 처리하는 REST 컨트롤러
  • 프론트엔드 : EventSource로 실시간 수신, fetch로 알림 생성/조회
graph TD A[Client/Front-end] -- "1. SSE Connect (EventSource)" --> B[SseController] A -- "2. API Request (fetch)" --> C[NotificationController] B --> D[SseEmitterService] C --> E[NotificationService] E --> F[(Database)] E --> D D -- "3. Real-time Push" --> A

왜 컨트롤러를 둘로 나누는지, 왜 @Async를 거는지 궁금하면 개념 3편을 참고하자. 여기서는 구현에 집중한다.

NotificationService 구현

단계 1 클래스 뼈대와 의존성 주입

service 패키지에 NotificationService를 생성한다.

@Service
@Slf4j
@RequiredArgsConstructor
public class NotificationService {
    private final NotificationRepository notificationRepository;
    private final SseEmitterService sseEmitterService;
}

두 가지 의존성이 필요하다. NotificationRepository는 알림을 DB에 저장하고, SseEmitterService는 SSE로 실시간 전송한다. 이 서비스가 둘 사이의 다리 역할을 한다.

단계 2 알림 생성과 전송

알림의 핵심 흐름은 "생성 → 저장 → 전송"이다. 이 순서가 중요하다.

sequenceDiagram participant C as Client participant NC as NotificationController participant NS as NotificationService participant NR as NotificationRepository participant SS as SseEmitterService C->>NC: POST /api/notifications NC->>NS: createAndSendNotification() NS->>NR: save(notification) NR-->>NS: savedNotification NS->>SS: sendToUser(userId, notification) SS-->>C: [SSE Event] notification NS-->>NC: notification NC-->>C: 200 OK (JSON)
@Transactional
public Notification createAndSendNotification(String userId, NotificationType type,
                                               String title, String message) {
    Notification notification = Notification.create(userId, type, title, message);
    notification = notificationRepository.save(notification);
    sendNotificationToUser(userId, notification);
    return notification;
}

@Transactional
public void createAndSendnotificationWithLink(String userId, NotificationType type,
                                               String title, String message, String link) {
    Notification notification = Notification.createWithLink(userId, type, title, message, link);
    notification = notificationRepository.save(notification);
    sendNotificationToUser(userId, notification);
}

DB 저장이 SSE 전송보다 먼저다. SSE 전송이 실패해도 알림은 DB에 남아 있으므로 사용자가 나중에 조회할 수 있다. 반대 순서면 SSE로 보냈는데 DB 저장이 실패하면 알림이 증발한다.

단계 3 비동기 전송

SSE 전송은 네트워크 I/O다. 알림을 생성한 쪽, 예를 들어 "댓글 작성" API 입장에서는 SSE 전송까지 기다릴 이유가 없다.

@Async
public void sendNotificationToUser(String userId, Notification notification) {
    sseEmitterService.sendToUser(userId, "notification", notification);
    log.info("실시간 알림 전송 성공 - 사용자: {}, 알림 ID: {}", userId, notification.getId());
}

@Async를 붙이면 이 메서드가 별도 스레드에서 실행된다. 호출한 쪽은 바로 다음 줄로 넘어가므로 응답 속도가 빨라진다.

sequenceDiagram participant MT as Main Thread participant AT as Async Thread (@Async) participant SS as SseEmitterService participant C as Client MT->>AT: sendNotificationToUser(notification) Note over MT: 즉시 응답 반환 MT-->>C: API Response Note over AT: 별도 스레드에서 실행 AT->>SS: sendToUser() SS-->>C: [SSE Event] Push
@Async의 프록시 함정

@Async는 스프링 AOP 프록시 기반이다. 같은 클래스 내부에서 this.sendNotificationToUser()를 호출하면 프록시를 거치지 않아서 비동기가 적용되지 않는다. 여기서는 createAndSendNotification에서 sendNotificationToUser를 호출하는데, 같은 클래스 안이므로 실제로는 동기로 실행된다. 진짜 비동기를 원하면 전송 로직을 별도 클래스로 분리해야 한다.

단계 4 조회와 읽음 처리

알림의 CRUD 로직은 직관적이다.

@Transactional(readOnly = true)
public List<Notification> getUserNotifications(String userId) {
    return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
}

@Transactional(readOnly = true)
public List<Notification> getUnreadNotifications(String userId) {
    return notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
}

@Transactional(readOnly = true)
public long getUnreadCount(String userId) {
    return notificationRepository.countByUserIdAndReadFalse(userId);
}

조회 메서드에는 @Transactional(readOnly = true)를 붙인다. 데이터를 변경하지 않으므로 JPA가 더티 체킹을 건너뛸 수 있어 성능상 유리하다.

@Transactional
public void markAsRead(Long notificationId) {
    Notification notification = notificationRepository.findById(notificationId)
            .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다: " + notificationId));
    notification.markAsRead();
    notificationRepository.save(notification);
}

@Transactional
public void markAllAsRead(String userId) {
    List<Notification> unreadNotifications =
            notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
    unreadNotifications.forEach(Notification::markAsRead);
    notificationRepository.saveAll(unreadNotifications);
}

@Transactional
public void deleteNotification(Long notificationId) {
    notificationRepository.deleteById(notificationId);
}

markAllAsRead는 해당 사용자의 읽지 않은 알림을 전부 가져와서 한 번에 읽음 처리한다. 알림이 쌓여있을 때 "모두 읽음" 버튼에 쓰인다.

단계 5 브로드캐스트

전체 공지는 특정 사용자가 아니라 연결된 모든 사용자에게 보내는 알림이다.

public void broadcastAnnouncement(NotificationController.AnnouncementRequest request) {
    Notification announcement = Notification.builder()
            .userId("ALL")
            .type(NotificationType.ANNOUNCEMENT)
            .title(request.title())
            .message(request.message())
            .read(false)
            .createdAt(LocalDateTime.now())
            .build();
    notificationRepository.save(announcement);
    sseEmitterService.broadcast("announcement", announcement);
}

수신자가 "ALL"이므로 userId에 특수 값을 넣고, SseEmitterService.broadcast()로 모든 emitter에 전송한다.

SseController 구현

SSE 연결만 전담하는 컨트롤러를 만든다. REST 컨트롤러와 분리하는 이유는 Content-Type이 다르기 때문이다. SSE는 text/event-stream, REST는 application/json이다.

@RestController
@Slf4j
@RequestMapping("/api/sse")
@RequiredArgsConstructor
public class SseController {
    private final SseEmitterService sseEmitterService;

    @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter connect(@RequestParam String userId) {
        log.info("SSE Connecting to user {}", userId);
        return sseEmitterService.createEmitter(userId);
    }

    @PostMapping("/disconnect")
    public void disconnect(@RequestParam String userId) {
        sseEmitterService.closeEmitter(userId);
    }

    @GetMapping("/connection-count")
    public int getConnectionCount() {
        return sseEmitterService.getConnectedUserCount();
    }

    @GetMapping("/is-connected")
    public boolean isConnected(@RequestParam String userId) {
        return sseEmitterService.isConnected(userId);
    }
}

/connect 엔드포인트가 핵심이다. produces = MediaType.TEXT_EVENT_STREAM_VALUE를 지정해서 응답 Content-Type을 text/event-stream으로 설정한다. 브라우저의 EventSource가 이 엔드포인트에 연결하면 SseEmitter가 반환되고, 이후부터 서버가 이 emitter를 통해 이벤트를 밀어넣는다.

나머지 엔드포인트(disconnect, connection-count, is-connected)는 디버깅이나 관리 용도다.

이 버전은 lastEventId가 없다

지금 만드는 connectlastEventId 파라미터가 없는 초기 버전이다. 이벤트 유실 복구는 다음 편에서 추가한다.

NotificationController 구현

알림의 CRUD를 담당하는 REST 컨트롤러다.

@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
@Slf4j
public class NotificationController {
    private final NotificationService notificationService;

    @PostMapping
    public ResponseEntity<Notification> createNotification(@RequestBody NotificationRequest request) {
        Notification notification = notificationService.createAndSendNotification(
                request.userId(), request.type(), request.title(), request.message());
        return ResponseEntity.ok(notification);
    }

    @PostMapping("/broadcast")
    public ResponseEntity<Void> broadcastNotification(@RequestBody AnnouncementRequest request) {
        notificationService.broadcastAnnouncement(request);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/user/{userId}")
    public ResponseEntity<List<Notification>> getUserNotifications(@PathVariable String userId) {
        return ResponseEntity.ok(notificationService.getUserNotifications(userId));
    }

    @GetMapping("/user/{userId}/unread-count")
    public ResponseEntity<Map<String, Long>> getUnreadCount(@PathVariable String userId) {
        long count = notificationService.getUnreadCount(userId);
        return ResponseEntity.ok(Map.of("count", count));
    }

    @PutMapping("/{notificationId}/read")
    public ResponseEntity<Void> markAsRead(@PathVariable Long notificationId) {
        notificationService.markAsRead(notificationId);
        return ResponseEntity.ok().build();
    }

    public record NotificationRequest(String userId, NotificationType type,
                                       String title, String message) {}
    public record AnnouncementRequest(String title, String message) {}
}

요청 DTO를 record로 선언했다. Java의 record는 불변 데이터 객체를 한 줄로 만들어준다. getter, equals, hashCode가 자동 생성되므로 DTO 용도에 딱 맞다.

컨트롤러 안에 inner record로 선언한 이유는 이 DTO가 오직 이 컨트롤러에서만 쓰이기 때문이다. 별도 파일로 뺄 만큼 복잡하지 않고, API 스펙을 한 파일에서 볼 수 있어서 가독성이 좋다.

프론트엔드 구현

프론트엔드는 두 개의 통신 채널을 동시에 사용한다:

graph LR subgraph Browser ES[EventSource] F[fetch API] end subgraph Server SC[SseController] NC[NotificationController] end ES -- "GET /api/sse/connect
(연결 유지)" --> SC SC -. "Server Push
(text/event-stream)" .-> ES F -- "POST/GET /api/notifications
(요청/응답)" --> NC NC -- "application/json" --> F
  • EventSource : 서버가 밀어주는 실시간 데이터를 받는다. 수신 전용이다.
  • fetch API : 알림 생성, 조회, 읽음 처리 등 클라이언트가 서버에 요청을 보낼 때 쓴다.

단계 1 SSE 연결

let eventSource = null;
let currentUserId = null;
let notifications = [];

function connect() {
    const userId = document.getElementById('userIdInput').value.trim();
    if (!userId) { alert('사용자 ID를 입력해주세요.'); return; }
    currentUserId = userId;
    if (eventSource) eventSource.close();

    eventSource = new EventSource(`/api/sse/connect?userId=${userId}`);
}

EventSource 생성자에 SSE 엔드포인트 URL을 넘기면 브라우저가 자동으로 연결을 맺는다. 기존 연결이 있으면 close()로 먼저 끊고 새로 연결한다. 중복 연결을 방지하기 위해서다.

단계 2 이벤트 리스너 등록

eventSource.addEventListener('connect', (event) => {
    console.log('SSE 연결 성공:', event.data);
    updateConnectionStatus(true);
});

eventSource.addEventListener('notification', (event) => {
    const notification = JSON.parse(event.data);
    addNotificationToList(notification, true);
    updateUnreadCount();
    showNotificationToast(notification);
});

eventSource.addEventListener('announcement', (event) => {
    const announcement = JSON.parse(event.data);
    addNotificationToList(announcement, true);
    showNotificationToast(announcement);
});

eventSource.onerror = (error) => {
    console.error('SSE 에러:', error);
    updateConnectionStatus(false);
};

서버에서 보내는 이벤트 이름(connect, notification, announcement)에 맞춰 리스너를 등록한다.

주의할 점이 있다. addEventListener로 등록하는 이벤트는 SSE의 event: 필드에 지정한 이름과 정확히 일치해야 한다. 서버에서 SseEmitter.event().name("notification")으로 보내면 클라이언트에서도 addEventListener('notification', ...)으로 받는다. 이름이 하나라도 다르면 이벤트를 수신할 수 없다.

onerror는 연결이 끊기거나 네트워크 오류가 발생할 때 호출된다. EventSource는 기본적으로 자동 재연결을 시도하므로, 에러 핸들러에서는 UI 상태만 갱신하면 된다.

단계 3 알림 생성과 브로드캐스트

async function sendTestNotification() {
    await fetch('/api/notifications', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            userId: currentUserId,
            type: document.getElementById('notificationType').value,
            title: document.getElementById('notificationTitle').value,
            message: document.getElementById('notificationMessage').value
        })
    });
}

async function sendBroadcast() {
    await fetch('/api/notifications/broadcast', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            title: document.getElementById('notificationTitle').value,
            message: document.getElementById('notificationMessage').value
        })
    });
}

알림을 보내는 건 SSE가 아니라 일반 REST API다. fetch로 POST 요청을 보내면 서버의 NotificationController가 받아서 NotificationService를 호출한다. 서비스에서 DB 저장 후 SSE로 전송하면, 연결된 브라우저의 EventSource 리스너가 받는다.

이 흐름을 정리하면 이렇다:

  1. 프론트 → fetch POST → NotificationController
  2. NotificationControllerNotificationService.createAndSendNotification()
  3. NotificationService → DB 저장 → SseEmitterService.sendToUser()
  4. SseEmitterServiceSseEmitter.send() → 브라우저 EventSource 리스너

동작 확인

서버 실행과 SSE 연결

  1. 애플리케이션을 실행한다.
  2. 브라우저에서 http://localhost:8080에 접속한다.
  3. 사용자 ID 입력란에 user1을 입력하고 연결 버튼을 클릭한다.
  4. 브라우저 개발자 도구의 Console에서 SSE 연결 성공 로그를 확인한다.

테스트 알림 전송

  1. 알림 제목과 메시지를 입력한다.
  2. "알림 전송" 버튼을 클릭한다.
  3. 알림 목록에 새 알림이 추가되고, 토스트 알림이 표시되는지 확인한다.

브로드캐스트 확인

  1. 브라우저 탭을 하나 더 열어서 user2로 연결한다.
  2. 한쪽에서 브로드캐스트를 전송한다.
  3. user1user2 탭 모두에서 공지 알림을 수신하는지 확인한다.

개발자 도구에서 SSE 확인

브라우저 개발자 도구 → Network 탭에서 connect 요청을 클릭하면 EventStream 탭이 보인다. 여기서 서버가 보내는 이벤트를 실시간으로 확인할 수 있다.

Network 탭에서 connect 요청이 pending 상태인 게 정상이다

SSE 연결은 서버가 응답을 끝내지 않고 계속 열어두는 방식이다. 브라우저 입장에서는 응답이 "진행 중"이므로 pending으로 표시된다.

자주 하는 실수

EventSource 이벤트 이름 불일치

서버에서 SseEmitter.event().name("notification")으로 보내는데, 클라이언트에서 eventSource.onmessage로 받으려고 하면 수신되지 않는다. onmessage는 이벤트 이름이 지정되지 않은 기본 이벤트만 받는다. 이름이 있는 이벤트는 반드시 addEventListener('이벤트이름', ...)으로 받아야 한다.

[!DANGER] Content-Type을 지정하지 않은 fetch

POST 요청에서 Content-Type: application/json 헤더를 빠뜨리면 스프링이 요청 본문을 파싱하지 못해 400 에러가 발생한다. @RequestBody는 Content-Type을 보고 어떤 HttpMessageConverter를 쓸지 결정하기 때문이다.

[!DANGER] SSE와 REST 엔드포인트를 한 컨트롤러에 합침

produces 설정이 충돌한다. SSE는 text/event-stream, REST는 application/json이다. 클래스 레벨 @RequestMappingproduces를 지정하면 모든 엔드포인트에 적용되므로, 반드시 컨트롤러를 분리해야 한다.