Spring WebSocket & Async

검색팀 송원석

YSM 사내 교육자료

요구사항

하루치 키워드별

검색 상품수를 뽑아주세요

  1. 하루 약 20만 키워드
  2. 실제 검색을 하나하나 날려봐야 검색 카운트를 파악 가능

  3. 20만 키워드 검색시 예상 소요시간 : 4~5시간

아귀찮

처음엔 단순하게

1. 키워드 목록을 쭉 가져와서

2. 키워드마다 다시 서버 호출

3. 받은 result값으로 화면 갱신

//키워드마다 다시 ajax call
$.ajax("searchCount.do", {
	data : {},
	type : 'POST',
	tryCount: 0,
	retryLimit : 5,
	success : function(data) {
            if(data) {
                // 화면갱신
            }
        }
            
});
//검색시작 버튼 클릭시
$("#start").click(function(){
	$.ajax("getKeywordList.do",{
		data : {
			"hello" : "world"
		},
		success : function(result) {
			totalCount = result.totalCount;
			list = result.list;
			//키워드 하나씩 계속 루프 돌면서 처리
			search();
		}
	});
});
$("#search0").text(searchSummary + requestCnt);
$("#keyword0").text(++keywordSummary);
$("#click0").text(clickSummary + clickCnt);
$("#searchClick0").text(searchClickSummary.toFixed(3) );

참쉽죠?

대충 만드니 문제발생

  • 나한테만 실행결과 보임
  • 화면을 새로고침 하면 모든 데이터가 날라감!
  • 잠깐이라도 네트워크가 끊기면 문제발생
  • 브라우저 화면을 못 끔 (끄면 처음부터 다시)
  • 화면에 뿌린 수십만 키워드를 복사하니 브라우저가 사망(!)

어떻게 하지?

서버에서 백그라운드로 돌아가게 바꿔볼까?

예상되는 문제점

  1. 긴 request로 인한 Connection Timeout
  2. progress를 실시간으로 보지못함 (어느정도 실행 됐나?)
  3. 만일 여러사람이 실행 한다면? 데이터가 꼬이지 않을까?
  4. Batch는 너무 잔손이 많이간다. (쉽게쉽게 갈 수 없을까?)

@Async

이렇게 바꿔봤어요

AS-IS TO-BE
서버 호출 키워드 건바이건 딱 한번만 호출
키워드목록 클라이언트에서 루프처리 서버에서 루프처리
진행 progress 정보 건바이건 자바스크립트 연산 static하게 서버에 정보를 가지고있음
화면 갱신 건바이건 자바스크립트 화면갱신 주기적으로 서버를 호출해서 진행 정보 가지고옴
실행 종료시 X 엑셀로 export해서 다운로드 가능하게 처리

설정은 이렇게

 <task:executor id="asyncExecutor" pool-size="10-100" queue-capacity="10"  rejection-policy="ABORT" />
 <task:annotation-driven executor="asyncExecutor" />

XML 기반

사용은 이렇게

@Async
public void existsCheckList(SearchRequestBean reqBean, HashMap<String, String> param) throws InterruptedException {
	logger.info("exists shop job start");
	
        // 잡 초기화
	Job job = JobTaskUtil.startJob(CommonUtil.EXISTS_SHOP_JOB_ID);
	
        // 키워드 목록을 가지고온다.	
	List<KeywordLogBean> keywordList = searchDAO.getKeywordList(param);
		
		
	job.setTotalCount(keywordList.size());
		
	reqBean.setSiteid("1");
	reqBean.setMode("simple");
	reqBean.setPageSize(1);
		
	if (StringUtils.isEmpty(reqBean.getServer())) {
		reqBean.setServer(Const.TEST_SERVER);
	}
	
	String opt = CommonUtil.getLastQueryType();
	
	int progressCount = 0;
	// 키워드 마다 루프를 돔
	for (KeywordLogBean keyword : keywordList) {
		if (!job.getIsRunning()) {
			break;
		}
		
		if (StringUtils.isEmpty(opt)) {
			opt = CommonUtil.getLastQueryType();
		}
		reqBean.setKwd(keyword.getKeyword());
		
		String[] kql_str = kqlGenerator.makeKQLString2(opt, reqBean, true);
		// 키워드별로 코난 검색엔진 호출
		SearchResultBean resultList = KonanModule.searchTestList(kql_str, reqBean);
		
		
		if (resultList.getTotalCount() <= 0 && StringUtils.isEmpty(reqBean.getSid())) {
			resultList = KonanModule.searchTestList2(kql_str, reqBean);
		}
		// 카운트를 받아와서 체크
		int count =  resultList.getTotalCount() + resultList.getSecondTotalCount();
		
		if (count > 0) {
			HashMap<String, String> map = new HashMap<>();
			map.put("keyword", keyword.getKeyword());
			map.put("count", Integer.toString(count));
			
			job.getList().add(map);
		}
		
		progressCount++;
		
		job.setProgressCount(progressCount);
	}
	
	// 엑셀 생성 로직 이관
	
	JobTaskUtil.completeJob(job.getJobId());
	
	logger.info("exists shop job done");
}

method에 @Async 만 쓰면 OK

@Async사용시 http thread가 아닌 별도의 thread를 내부적으로 만들어서 사용

@Async 사용시 주의점

  1. method 타입은 void혹은 Future타입만 가능 (Future는 callback 처럼 실행됨)
  2. public method 만 가능
  3. async method내부에서 response, multipartfile등을 제어하지 말자. async method가 동작하는 타이밍엔 이미 request가 끝난 상태기 때문에 알 수 없는 에러발생
  4. 꼭 method 내부에서 try catch 걸어놓자. throws안먹힘. 에러는 꼭 method 내부에서 처리해야함.

잘 동작은 하는데...

화면갱신이 예전보다 매끄럽지 못함

건by건 -> 가끔으로 다운그레이드됨

websocket을 써볼까?

nodejs에서는 내가 금방 세팅 했었는데...

욕심의 서막;

Spring WebSocket

우선 문서를 보고 설정

<web-app
                xmlns="http://java.sun.com/xml/ns/javaee"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://java.sun.com/xml/ns/javaee
        http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    version="3.0">

    <absolute-ordering>
        <name>spring_web</name>
    </absolute-ordering>

</web-app>
            
  1. servlet3.0을 서버에서 지원해야함
    (tomcat7.0.47+, Jetty 9.1+)
     
  2. 현재 spring application이 가장 높은 우선순위를 가지게 설정

우선 문서를 보고 설정2

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/myHandler").withSockJS();
    }

    @Bean
    public WebSocketHandler myHandler() {
        return new MyHandler();
    }

}
  1. @EnableWebSocket 으로 websocket을 사용하게 선언
  2. .withSocketJS() 설정으로 URL을 socketJS와 연동 설정

안된다...

SocketJs에서 알 수 없는 404에러 발생

STOMP 설정을 안해서 그런가?

import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/portfolio").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/app");
        config.enableSimpleBroker("/topic", "/queue");
    }

}

그래도 안된다...

이번엔 stomp에서 알 수 없는 404에러 발생

원인은

  <servlet-mapping>
  	<servlet-name>spring</servlet-name>
  	<url-pattern>/</url-pattern>
  </servlet-mapping>
  <absolute-ordering>
  	<name>spring</name>
  </absolute-ordering>

servlet-mapping의 url-pattern이 특정 확장자로만 지정

되어 있으면 오류 발생.

url-pattern을 / 로 설정해서

모든 request를 spring에서 받게 설정

그래도 안되지만 다른에러

모든 스크립트 & 이미지파일 404에러 발생

resource handler

spring 설정에서 js와 image경로를 resource handler로 설정

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/common/**").addResourceLocations("/common/");
    registry.addResourceHandler("/images/**").addResourceLocations("/images/").setCachePeriod(31556926);
}

it works!

힘든 여정이었다

사용방법

JSP에선

<script src="<c:url value='/common/js/sockjs-0.3.4.js'/>"></script>
<script src="<c:url value='/common/js/stomp.js'/>"></script>
var sock = new SockJS('/RankPilot/websocket');
stompClient = Stomp.over(sock);
		 
stompClient.connect({}, function(frame) {
  console.log('Connected: ' + frame);
             
  stompClient.subscribe('/topic/jobStatus_checkSearchCount', function(obj){
    if (obj) {
      var jobStatus = JSON.parse(obj.body);
                	 
      console.info(jobStatus);
    }
  });
});

JAVA에선

@Configuration
@EnableWebSocketMessageBroker
public class AppWebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/websocket", "notify", "/jobStatus*").withSockJS();
  }
	
  @Override
  public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/topic");
    registry.setApplicationDestinationPrefixes("/RankPilot");
  }
}

client -> server

stompClient.send('/topic/notify', {}, 'hello world');
@Controller
public class WebsocketController {
	@MessageMapping("/notify")
	@SendTo("/topic/notify")
	public String notify(String message) {
		return message;
	}

}

server -> client

@Autowired
private SimpMessagingTemplate template;

public void sendWebsocketJobStatus(String jobId, Job job) {
	HashMap<String, Object> returnMap = new HashMap<>();
		
	returnMap.put("totalCount", job.getTotalCount());
	returnMap.put("progressCount", job.getProgressCount());
	returnMap.put("isRunning", job.getIsRunning());
	returnMap.put("reportStatus", job.getReportStatus());
	returnMap.put("errorMsg", job.getErrorMsg());
	returnMap.put("totalTimeMsg", job.getTotalTimeMsg());
		
	template.convertAndSend("/topic/jobStatus_" + jobId, returnMap);
}
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-messaging</artifactId>
  <version>${spring-version}</version>
</dependency>

server -> client

var sock = new SockJS('/RankPilot/websocket');
stompClient = Stomp.over(sock);
stompClient.connect({}, function(frame) {
  console.log('Connected: ' + frame);
             
  stompClient.subscribe('/topic/jobStatus_checkSearchCount', function(obj){
    if (obj) {
       var jobStatus = JSON.parse(obj.body);
                	 
        $(".endCount").html(jobStatus.progressCount);
        $("#endProgress").html(jobStatus.totalCount);
        var percent = jobStatus.progressCount / jobStatus.totalCount * 100;
     				
     	$("#progressBar").css({width: (percent + "%")});
     				
     	$("#jobPercent").html(Math.floor(percent));
     				
     	$("#totalTimeMsg").html("[약 " +jobStatus.totalTimeMsg + "]");
    }
  });
});

server -> client

특정 사용자에게만 메시지 전송의 경우

@SendToUser or 

template.convertAndSendToUser 사용

stompClient.subscribe(destination, callback, { id: mysubid });

시연

Q&A

참고문헌

다음주제선정

  • Basic of Javascript
  • Eclipse IDE 꿀팁
  • Apache Zeppelin
  • 쿼리를 빠르게 하자(기초과정)
  • 디버깅과 테스트
Made with Slides.com