2018년 5월 23일

스프링 기반의 Server-Sent Events 기술을 이용한 웹 기반 알림 구현


HTML5 Server-Sent Events (이하 SSE )를 이용하면 비교적 손쉽게 필요할때마다 클라이언트에 데이터를 전송하는 서버 PUSH 구현이 가능하다.

그럼 SSE 는 무엇인가?

전통적인 웹 기술로 폴링(POLLING) 불리우는 것이 있다.
이 기술은 응용프로그램이 요청을 통하여 서버에서 데이터를 반복적으로 가져오는 것으로 AJAX 에서 널이 사용하고 있다.  단점이라면 클라이언트는 요청을 하고 서버의 응답을 기다리는 것으로 경우에따라서는 의미없는 데이터를 받는 경우가 많기 때문에 HTTP 오버헤드를 유발하게 된다. (서버가 전달할 메시지가 있는지 없는 지를 알수 없기 때문에 주기적으로 호출해야 함.)

보다 개선된 방법으로 긴 폴링(Hanging GET/COMET) 기술이 있다. 긴 폴링은 서버는 새로운 데이터가 없는 경우 클라이언트로 부터의 요청을 보류한다.  새로운 데이터가 있는 경우 응답을 하고 연결을 종료하게 된다. 가장 손쉬운 예가 iframe에 스크립트를 추가하여 서버의 응답이 있는 경우 콘텐츠가 갱신된도록 구현하는 것이 될 것 같다.

SSE 는 이러한 전통적인 웹 기술들과 다르게 처음부터 효율적으로 메시지를 클라이언트에 보낼 수 있도록 설계되었다. SSE를 사용하여 통신 할 때 서버는 초기 요청을 하지 않고도 필요할 때마다 데이터를 클라이언트에게 푸시 할 수 있다. 즉, 서버에서 클라이언트로 업데이트된 데이터를 스트리밍 할 수 있다는 의미이다. 비교적 많이 알려진 WebSocket 기술과 비교하면 양방향이 아닌 단방향 통신을 지원하며 별도의 서버구현 또는 프로토콜 없이 HTTP 기술을 통하여 구현할 수 있다는 점이다. (이점이 아주 매력적이다.)


SSE 기술을 이용한 스프링 환경에서 웹 푸시 구현


간단한 이슈관리 웹 프로그램이 있다. 이 웹 프로그램에서 새로운 이슈 또는 변경이 발생하는 경우 클라이언트에게 알림을 보여주는 것을 구현해보자. 여기에 브라우져가 PWA 를 지원하는 경우 이를 이용한 푸시 알람도 같이 구현해보자. 

아쉬은 것은 아직까지 익스플로러는 SSE 기술을 지원하지 않아 사용할 수 없다는 점이다. 다행스러운 것은 아주 방법이 없는 것은 아니여서 polyfill 스크립트를 사용하여 IE10, IE11에서 동작하도록 했다.


SSE 기술을 지원하는 브라우져
BrowserSupportedNotes
Internet ExplorerNo
Mozilla FirefoxYesStarting with Firefox 6.0
Google ChromeYesStarting with Chrome 6
OperaYesStarting with Opera 11
SafariYesStarting with Safari 5.0

개발 환경 


  • Springframework 4.3.2 
  • Tomcat 8.0
  • Java 8.3.2 


개발방법

이슈 생성/변경시 푸시 알람을 구현하기 위하여 서버 프로그램은 

  1. 새로운 이슈가 등록되거나 변경되면 
  2. Springframework 의 비동기 이벤트 기술을 사용하여 이벤트를 발생시킨다.
  3. Springframework 에서 지원하는 SSE기능을 사용하여 발생된 이벤트를 클라이언트에 전송한다.
그림 1. PUSH 알림 동작 원리


스프링 컨트롤러에서 SSE 사용하기 

스프링은 SseEmitter 불리우는 클래스를 사용하여 HTTP 응답을 text/event-stream 타입으로 리턴한다.
이를 위해서 아래와 같이 web.xml 파일에 sync-support 를 반듯이 추가해주어야 한다.
    
        community-servlet
         org.springframework.web.servlet.DispatcherServlet
        
            contextConfigLocation
            
                /WEB-INF/servlet-config/community-servlet-context.xml
            
        
        1
        true
    

이슈를 저장하는 서비스는 아래와 같은 형식으로 코딩한다. 저장이 완료되면 Spring 에서 제공하는 EventPublisher 를 사용하여 이벤트를 발생시키면 된다. 

package architecture.community.projects;
public class DefaultProjectService extends EventSupport implements ProjectService {
    private Logger logger = LoggerFactory.getLogger(getClass().getName());
    
    @Autowired(required = false) 
    private ApplicationEventPublisher eventPublisher;

    public DefaultProjectService(){}    

    @Transactional(readOnly = false, propagation = Propagation.REQUIRES_NEW)
    public void saveOrUpdateIssue(Issue issue) {
        boolean isNew = true;
        IssueStateChangeEvent.State state ;

        if( issue.getIssueId() > 0 ) {
            isNew = false;
            state = IssueStateChangeEvent.State.UPDATED;
        }else {
            state = IssueStateChangeEvent.State.CREATED;
        }

        if( issue.getIssueId() > 0 && projectIssueCache.get(issue.getIssueId()) != null  ) {
            projectIssueCache.remove(issue.getIssueId());
        } 

        clearProjectStats(issue.getObjectId());
        logger.debug("save or update user {}" , issue );
        projectDao.saveOrUpdateIssue(issue);

        if( eventPublisher != null)
            eventPublisher.fireEvent(new IssueStateChangeEvent( issue, SecurityHelper.getUser(), state ));        
    }
}

컨트롤러 코드에서는 아래와 같은 방식으로 이벤트를 리스닝하다 이벤트가 발생되면 아래와 같은 방법으로 코딩을 한다.

package architecture.community.web.spring.controller.data.v1;


import java.util.ArrayList;

import java.util.List;

import java.util.concurrent.CopyOnWriteArrayList;


import org.springframework.context.event.EventListener;

import org.springframework.security.access.annotation.Secured;

import org.springframework.stereotype.Controller;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;


import architecture.community.projects.event.IssueStateChangeEvent;


@Controller("sse-data-controller")
@RequestMapping("/data/api/v1/notifications")

public class SSEController {


    private final CopyOnWriteArrayList emitters = new CopyOnWriteArrayList<>();


    public SSEController() {

    }


    @Secured({ "ROLE_USER" })

    @GetMapping("/issue.json")

    public SseEmitter handle() { 

        final SseEmitter emitter = new SseEmitter();

        this.emitters.add(emitter); 

        emitter.onCompletion( new Runnable() {

            public void run() {

                emitters.remove(emitter); 

            }

        }); 

        emitter.onTimeout( new Runnable() {

            public void run() {

                emitters.remove(emitter); 

            }
        });
        return emitter;
    }

    @EventListener
    public void onIssueStateChangeEvent(IssueStateChangeEvent event) {
        List deadEmitters = new ArrayList<>();
        for(SseEmitter emitter : emitters ) { 
            try {
                emitter.send(event);
            } catch (Exception e) {
                deadEmitters.add(emitter);
            }     
        }
        this.emitters.removeAll(deadEmitters);
    } 
}

클라이언트 즉 웹 페이지에서 PUSH 알림 구현하기

먼저  IE 는 SSE 를 지원하지 않기 때문에 EventSource.js 파일을 다운받아 사용한다.
사용하는 방법은 기존 EventSource 와 동일하다. 참고로 데스크탑 알림은 반듯이 웹 사이트가 https 인경우에만 동작하기 때문에 이경우는 다른 방법을 알림을 제공하도록 kendoui 를 사용하여 구현하였다.

    function createNotification(observable){ 

        console.log('show notification... : ' + observable.get('notificationEnabled') );
        console.log("create notification..");
        if(window.Notification){
            if( Notification.permission == 'denied' ){
                observable.set('notificationEnabled', false);
            }else{
                observable.set('notificationEnabled', true);
            }    
        }            

        var template = kendo.template($("#notification-template").html());
        const eventSource = new EventSource('/data/api/v1/notifications/issue.json'); 

        eventSource.onmessage = function(e) { 
            console.log('msg: ' + e.data);
            var obj = JSON.parse(e.data);
            var title = "";
            if( obj.state == 'CREATED' ){
                title = "신규 이슈 알림";
            }else {
                title = "이슈 변경 알림";
            } 

            if(observable.get('notificationEnabled')){
                 var notification = new Notification(title, {
                        body: template(obj),
                        icon: iconDataURI
                });         
            }else{
                title = title + " : " + new Date().toLocaleTimeString() ;
                community.ui.notification({ 
                    autoHideAfter:0, 
                    allowHideAfter: 0,
                    width : 500,
                    templates : [{
                        type : "alert",
                        template : '
#if(title){#
#= title #
#}#
#= message #
' }] }).show({ title:title, 'message': template(obj), time: new Date().toLocaleTimeString() },"alert"); } return; } }

일반적인 웹 페이지에서 알림이 발생된 경우 예시 화면은 아래와 같다.



참고 사이트

Server-Sent Events with Spring
Use Server-Sent Event in Spring 4.2
Spring Events
프로그레시브 웹앱(PWA) 푸시 알람 A to Z
W3C Server-Sent Events
What is a Polyfill
Stream Updates with Server-Sent Events
Is there a Microsoft equivalent for HTML5 Server-Sent Events?

댓글 3개:

  1. 여기서 Extends EventSupport는 설명이 없나요?

    답글삭제
    답글
    1. EventSupport 클래스는 DEADCODE 라고 생각하시면 됩니다.

      삭제
  2. EventSupport, ProjectServicesm 는 어디에있어요 ?

    답글삭제