본문 바로가기
IT/Web

SSE (Server-Send Event) 를 이용한 다운로드 진행 상황 표시 (progressbar)

by 최고영회 2021. 4. 14.
728x90
반응형
SMALL

2009년인가.. 2010년인가 당시만 해도 웹어플리케이션을 만드는데 많은 제약들이 있었고 push server를 만든다는것에 기술적으로 다소 어려움이 있었다.

polling, long-polling 등으로 구현하던 도중 websocket 을 이용해서 push server 를 만들었던 기억이 난다.

지금은 엄청난 기술적 발전을 통해 손쉽게 push server를 만들 수 있으며

server event, message 를 client 로 보내는 일이 어려운일이 아닌것이 되었다.

웹 어플리케이션에서 대량 데이터를 file(ex. xlsx, csv)로 download 하는 것은 매우 일반적인 일이다.

그런데 '대량 데이터' 다운로드 하게 되면

데이터 저장소 (ex. 데이터베이스) 에서 데이터를 get 하고

필요한 포맷의 파일로 만들고 이것을 download 하는데 굉장히 많은 '시간' 이 소요됨을 알 수 있다.

이런 경우 progress bar를 통해 사용자에게 피드백을 주어야 사용자는 다운로드 되는 상황을 보면서 기다릴 수 있다. 보통의 파일이라면 간단한 loading gif 를 통해 대응해도 가능하지만 '대량 데이터' 의 경우 상당한 시간이 소요되기 때문에 사용자는 언제까지 기다려야 하는지 알수 없어 답답함을 느낄 수 있다.

정확한 진행 상태를 알려주려면 어떻게 해야 할까?

server 에서 발생하는 event (ex. 대량 데이터를 저장소로부터 얼만큼 get 해왔는지, 파일을 생성하고 있는지, 얼마나 생성하고 있는지 등)를 사용자에게 전달하여 상황을 공유하면 해결된다.

server의 event를 client로 push 할 수 있는 방법들은 위에서 언급한 polling.. long-polling 등이 있는데

이번에는 SSE를 이용할 것이다. 어떤 차이가 있는지 살펴보자.

polling

- 대부분의 ajax로 사용되는 전통적인 기술로 client 가 server로 반복적으로 요청하는 것을 말한다.

- polling은 http 오버헤드가 발생한다는 단점이 있다.

- 하지만 일정하게 갱신되는 서버 데이터의 경우 유용하게 사용할 수 있는 방식이다. (ex. 대시보드 갱신)

 

long-polling

- 서버에 사용 가능한 데이터가 없으면 새로운 데이터를 사용할 수 있을때까지 요청을 보류한다.

- hang걸린것 처럼 응답을 보류하기 때문에 hang get 이라고도 불린다.

- 특성상 구현을 위해 무한 iframe에 script tag를 추가하는 것과 같은 꼼수가 필요하다.

 

websocket

- 양방향 채널을 이용한다.

- websocket 프로토콜을 처리하기 위해 전이중 연결과 새로운 웹소켓 서버가 필요하다.

 

SSE(Server-Sent Events)

- HTML5 표준안이며 어느정도 웹소켓의 역할을 하면서 더 가볍다.

- websocket 과 같이 양방향이 아닌 server -> client 단방향이기에

서버의 event 나 message를 client 로 push 하는 작업에 유용하게 사용될 수 있다.

- 양방향이 아니기에 요청 시 ajax로 쉽게 이용할 수 있다.

- 재접속 처리 같은 대부분의 저수준 처리가 자동으로 지원됨.

- polyfill을 이용할 경우 IE를 포함한 크로스브라우징이 가능함 (망할 IE)

구현

Controller

@Controller
public class TestController {
	
	@Autowired
	DownloadService downloadService;
	
	@RequestMapping("/startdownload")
	public SseEmitter startDownload(HttpServletRequest req) {
		return downloadService.readyToDownload(req.getParameter("key"));
	}

	@RequestMapping("/filedownload")
	public ResponseEntity<Void> downloadHandle(HttpServletRequest req ) {
		downloadService.downloadFile(req.getParameter("key"));
		return new ResponseEntity<>(HttpStatus.OK);
	}
}

SseEmitter를 반환하는 controller 를 하나 준비하고

SseEmitter 객체를 만들고 service의 map에 담아놓는다. key는 view 쪽에서 uuid 와 같은 유니크 한 값을 만들어 보내면 된다.

view 에서는 download 버튼 클릭 시 startdownload API를 먼저 호출하여 SseEmitter를 반환 받는다.

view 에서는 EventSource를 통해 SseEmitter를 받을 수 있으며 전달받은 eventSource의 onmessage function을 통해 진행상태를 화면에 표시할 수 있다. (아래 view 쪽 소스코드 참고)

Service

@Service
public class DownloadService {
	private static final long ONE_HOUR =  60 * 1000;
	private ConcurrentHashMap<String, SseEmitterDto> userDownloadProgressMap = new ConcurrentHashMap<>();
	private static final DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
	
	public SseEmitter readyToDownload(String key) {
		SseEmitter emitter = new SseEmitter(-1L);
		userDownloadProgressMap.put(key, new SseEmitterDto(emitter));
		return emitter;
	}
	
	public void downloadFile(String key) {
		SseEmitterDto userEmitter = userDownloadProgressMap.get(key);
		if (userEmitter == null) {
			// return error, please retry download file 
		}
		
		// download business logic 
		try {
			// ex. get data from database
			for(int i=1; i<=50; i++) {
				Thread.sleep(ThreadLocalRandom.current().nextInt(1, 500));
				userEmitter.getEmitter().send("데이터 조회: "+(i*2));
			}
			// ex. create file from data
			for(int i=1; i<=50; i++) {
				Thread.sleep(ThreadLocalRandom.current().nextInt(1, 300));
				userEmitter.getEmitter().send("파일 생성: "+(i*2));
			}
			userEmitter.getEmitter().send("FIN");
			System.out.println("다운로드 완료 ");
		} catch (ClientAbortException ex) {
			System.out.println("client 에서 통신을 끊은 상황 ");
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			if (userEmitter != null && userEmitter.getEmitter() != null) {
				userEmitter.getEmitter().complete();
				userEmitter.setEmitter(null);
				userDownloadProgressMap.remove(key);
			}
		}
	}
	
	
	@Scheduled(cron = "0 0 */1 * * *")
	public void clearSseEmitter() {
		long now = System.currentTimeMillis() - ONE_HOUR;
		Iterator<String> it = userDownloadProgressMap.keys().asIterator();
		System.out.println("size: "+userDownloadProgressMap.size());
		while (it.hasNext()) {
			String key = it.next();
			System.out.println("key: "+key);
			SseEmitterDto se = userDownloadProgressMap.get(key);
			if (now > se.getTime()) {
				System.out.println("clear old ssemitter "+key+" : "+format.format(se.getTime()));
				if (se.getEmitter() != null) {
					se.setEmitter(null);
				}
				userDownloadProgressMap.remove(key);
			}
		}
	}
}

 

readyToDownload 에서는 새롭게 생성한 SseEmitter를 map에 담아두고 생성한 객체를 반환한다.

downloadFile 에서는 download 로직을 만들고 진행상황을 emitter.send를 통해 client 에게 보내면 된다.

빠른 테스트를 위해 business logic은 제외하고 sleep 과 상황(데이터 조회, 파일 생성)에 대한 percentage를 메시지로 보내도록 해 두었다.

clearSseEmitter 는 1시간에 한번씩 map에 담겨 있는 오래된 emitter를 clear 하는 역할을 한다.

downloadFile method의 finally 에서 emitter.complete 과 set null 처리, map에서 remove 하는 처리를 하고 있기 때문에 scheduler에 의해 remove 되는 일은 없겠으나 만에하나 쓰레기 정보가 메모리에 상주될 수 있을지 모르는 상황을 대비한 방어코드라고 보면 된다.

View

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Server-Sent Events Progress Bar Example</title>
    <!-- load the bootstrap stylesheet -->
    <link href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" media="all" rel="stylesheet" type="text/css" />
    <script  src="http://code.jquery.com/jquery-latest.min.js"></script>
	<style type="text/css">
	.displayNone {display: none;}
	</style>
    <script>
        var isClosed = true;
        var eventSource;
        var $log;
        $(document).ready(function(){
            if (!window.EventSource) {
                alert('Your browser does not support EventSource.');
                return;
            }
            
            $('#downloadBt').click(function(){
            	 eventSource = new EventSource('/startdownload');

                 eventSource.onopen = function(e) {
                     log("Connection is open");
                 };

                 eventSource.onerror = function(e) {
                     if (this.readyState == EventSource.CONNECTING) {
                         log("Connection is interrupted, connecting ...");
                     } else {
                         log("Error, state: " + this.readyState);
                     }
                 };

                 eventSource.onmessage = function(e) {
                     if (e.data == 'FIN'){
                         stop();
                     } else {
                         log(e.data+'%');
                     }
                 };
                 
                 setTimeout(function(){
                	 $('#loading_img').removeClass('displayNone');
                     startDownload();
                 }, 1000);
            });
            
            $log = $('#logMsg'); 
        });
        
        function startDownload(){
        	$('#downloadBt').prop('disabled', true);
        	$.ajax({
        		  url: "filedownload",
        	}).done(function( data ) {
        		log('다운로드 완료');
        		$('#downloadBt').prop('disabled', false);
        	});
        }
        
        function stop() {
            eventSource.close();
            isClosed = true;
            $('#loading_img').addClass('displayNone');
        }

        function log(msg) {
        	$log.html(msg);
        }
    </script>
</head>
<body>
<div class="container" style="padding-top:10px">
    <button id="downloadBt">Download File</button>
    <div id="loading_img" class="displayNone"><img src="loader.gif"></div>
    <div id="logMsg"></div>
</div>
</body>
</html>

 

download button click 시 connection을 맺어 EventSource 객체를 생성하고

download 를 호출하면 미리 얻은 eventSource 를 통해 server에서 보내는 메시지 (상황에 대한 percentage)를 수신받아 loading 창에 보여줄 수 있게 된다.

 

 

728x90
반응형
LIST