검색

레이블이 모바일인 게시물을 표시합니다. 모든 게시물 표시
레이블이 모바일인 게시물을 표시합니다. 모든 게시물 표시

2020년 6월 8일

Spring 기반 웹 프로그램에서 JWT 인증 사용하기

JWT ( JSON WEB TOKEN ) 는 URL 형식으로 구현할 수 있는 전자서명 기술이다. JWT 는 주요한 속성 정보를 RFC7519 표준에 따라 JSON 테이터 구조로 표현한다.

JWT 기술을 활용하면 손쉽게 클라이언트(앱) 기반 프로그램에서 토큰 기반 인증 및 REST API  호출을 구현할 수 있다.

클라이언트와 서버간의 인증 시나리오를 정리하면 아래와 같다. 

❶ 사용자가 로그인을 하면, ❷ 서버는 사용자의 정보를 기반으로한 JWT 토큰을 발급한다.
그 후, ❸ 사용자가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. ❹ 서버는 클라이언트에서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리.



Spring 기반의 서버 사이드  JWT

다행스러운 것은 JWT 토큰 생성과 검증을 위한 라이브러리가  JWT 사이트에서 다양한 언어 버전으로 제공하고 있어 적절하게 이를 활용하여 손쉽게 JWT 를 적용할 수 있다.



글에서는  "https://github.com/jwtk/jjwt" 를 사용하였다. 


서버 프로그램 개발환경이 maven 으로 구현되어 있어 아래와 같이 라이브러리 의존성을 추가하였다. 

<!--  JWT  -->

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->

<dependency>

    <groupId>io.jsonwebtoken</groupId>

    <artifactId>jjwt</artifactId>

            <version>0.9.1</version>

</dependency>


서버 프로그램이 Spring 을 기반으로 하고 있어 Spring Security 설정에 JWT 처리를 위한 커스텀 필터를 구현하여 추가하였다. (커스텀 필터는 클라이언트에서 헤더에 JWT 토큰을 가지고 오는 경우 이를 검증하고 인증 정보를 보안 컨텍스트에 추가하는 역할을 수행한다.)

package architecture.community.security.spring.authentication.jwt;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;

public class JwtTokenProvider {

	private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

	private static final String AUTHORITIES_KEY = "auth";

	/**
	 * JWT Token expire time.
	 */
	static final long EXPIRATIONTIME = 864_000_000; // 10 days

	/**
	 * Secret Key string
	 */
	static final String SECRET = "ThisIsASecret";

	/**
	 * JWT Token prefix
	 */
	static final String TOKEN_PREFIX = "Bearer";

	/**
	 * Header key for JWT Token
	 */
	static final String HEADER_STRING = "Authorization";

	public String createToken(Authentication authentication) {
		String authorities = authentication.getAuthorities().stream().map(authority -> authority.getAuthority())
				.collect(Collectors.joining(","));
		ZonedDateTime now = ZonedDateTime.now();
		ZonedDateTime expirationDateTime = now.plus(EXPIRATIONTIME, ChronoUnit.MILLIS);
		Date issueDate = Date.from(now.toInstant());
		Date expirationDate = Date.from(expirationDateTime.toInstant());
		return Jwts.builder().setSubject(authentication.getName()).claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS512, SECRET).setIssuedAt(issueDate).setExpiration(expirationDate)
				.compact();
	}

	public Authentication getAuthentication(String token, UserDetailsService userDetailsService, boolean refresh) {
		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
		UserDetails details = userDetailsService.loadUserByUsername(claims.getSubject());
		return new UsernamePasswordAuthenticationToken(details, "", refresh ? details.getAuthorities() : authorities);
	}

	public Authentication getAuthentication(String token) {

		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());

		User principal = new User(claims.getSubject(), "", authorities);

		return new UsernamePasswordAuthenticationToken(principal, "", authorities);

	}

	public boolean validateToken(String authToken) {
		try {
			Jwts.parser().setSigningKey(SECRET).parseClaimsJws(authToken);
			return true;
		} catch (SignatureException e) {
			logger.info("Invalid JWT signature: " + e.getMessage());
			logger.debug("Exception " + e.getMessage(), e);
		} catch (MalformedJwtException e) {
			logger.error("Invalid JWT token: {}", e.getMessage());
		} catch (ExpiredJwtException e) {
			logger.error("JWT token is expired: {}", e.getMessage());
		} catch (UnsupportedJwtException e) {
			logger.error("JWT token is unsupported: {}", e.getMessage());
		} catch (IllegalArgumentException e) {
			logger.error("JWT claims string is empty: {}", e.getMessage());
		}
		return false;
	}
}

(JwtTokenProvider 사용하여 Jwt 토큰을 검증하고 userDetailsService 을 이용하여 보안 컨텍스트에 인증 객체를 설정한다.)
package architecture.community.security.spring.authentication.jwt;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;

import architecture.ee.util.StringUtils;
import io.jsonwebtoken.ExpiredJwtException;

public class JWTFilter extends GenericFilterBean {

	private Logger logger = LoggerFactory.getLogger(getClass());

	private final JwtTokenProvider jwtTokenProvider;

	@Autowired(required = false)
	@Qualifier("userDetailsService")
	private UserDetailsService userDetailsService;

	public JWTFilter(JwtTokenProvider jwtTokenProvider) {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
		this.jwtTokenProvider = jwtTokenProvider;
	}

	@Override
	public void afterPropertiesSet() {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
	}

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		String header = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.isNullOrEmpty(header)) {
			chain.doFilter(request, response);
			return;
		}

		try {
			String jwt = this.resolveToken(request);
			if (!StringUtils.isNullOrEmpty(jwt)) {
				logger.debug("jwt token : {}", jwt);
				if (this.jwtTokenProvider.validateToken(jwt)) {
					Authentication authentication;
					if (userDetailsService != null) {
						authentication = this.jwtTokenProvider.getAuthentication(jwt, userDetailsService, true);
					} else {
						authentication = this.jwtTokenProvider.getAuthentication(jwt);
					}
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}
			}
		} catch (ExpiredJwtException eje) {
			logger.info("Security exception for user {} - {}", eje.getClaims().getSubject(), eje.getMessage());
			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
			logger.debug("Exception " + eje.getMessage(), eje);
			return;
		}

		chain.doFilter(request, response);
		this.resetAuthenticationAfterRequest();
	}

	private void resetAuthenticationAfterRequest() {
		logger.debug("reset authentication as null.");
		SecurityContextHolder.getContext().setAuthentication(null);
	}

	private String resolveToken(HttpServletRequest request) {
		String bearerToken = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
			String jwt = bearerToken.substring(7, bearerToken.length());
			return jwt;
		}
		return null;
	}
}  
  
필터를 정의하고 사용자 정의 필터를 추가해준다.

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  <custom-filter before="BASIC_AUTH_FILTER" ref="jwtFilter" />


  ...


</http>


<beans:bean id="jwtTokenProvider" class="architecture.community.security.spring.authentication.jwt.JwtTokenProvider"></beans:bean>

<beans:bean id="jwtFilter" class="architecture.community.security.spring.authentication.jwt.JWTFilter">

  <beans:constructor-arg ref="jwtTokenProvider" />

</beans:bean>



③ CORS 지원
주의할 것은 보안상 이유에서 웹 브라우져는 스크립트에서 웹페이지가 로드된 원 도메인(IP)이 아닌 타 도메인(IP) 자원을 호출하는 것을 금지하고 있다. 

CORS(Cross-origin resource sharing) 는 대부분의 웹 브라우져들이 구현하고 있는 W3C 표준이며 이를 통하여 다른 도메인의 자원을 호출 할 수 있게 할 수 있다. Spring Security 는 필터 기반의 CORS 를 지원하고 있어 아래와 같이 손쉽게 처리가 가능하다. 

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  ...


</http>


<beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">

  <beans:property name="corsConfigurations">

    <util:map>

      <beans:entry key="/**">

      <beans:bean class="org.springframework.web.cors.CorsConfiguration">

      <beans:property name="allowCredentials" value="true"/>

      <beans:property name="allowedHeaders">

      <beans:list>

        <beans:value>Authorization</beans:value>

        <beans:value>Content-Type</beans:value>

        <beans:value>responseType</beans:value>

        <beans:value>encoding</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="allowedMethods">

        <beans:list>

        <beans:value>POST</beans:value>

        <beans:value>GET</beans:value>

        <beans:value>PUT</beans:value>

        <beans:value>DELETE</beans:value>

        <beans:value>OPTIONS</beans:value>

        </beans:list>

      </beans:property>

      <beans:property name="allowedOrigins" value="*" />

      <beans:property name="exposedHeaders">

      <beans:list>

        <beans:value>Location</beans:value>

        <beans:value>Content-Disposition</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="maxAge" value="86400" /></beans:bean>

    </beans:entry>

    </util:map>

  </beans:property>

</beans:bean>


JWT 기반 인증 및 검증

아이디/비밀번호 기반의 인증에서 JWT 토튼을 사용하려면 인증이 성공하면 클라이언트에 토큰 값을 응답해주어야 한다. 아래는 스프링 컨트롤러에 간단하게 로그인 인증후 Jwt 토큰 값을 리턴하는 예이다.

LoginRequest.java

@RequestMapping(value = "/signin.json", method = { RequestMethod.POST})

public ResponseEntity<JwtResponse> authenticateUser(@RequestBody LoginRequest loginRequest) { 

Authentication authentication = authenticationManager.authenticate(

new UsernamePasswordAuthenticationToken(

loginRequest.getUsername(), 

loginRequest.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);

String jwt = jwtTokenProvider.createToken(authentication); 

CommuintyUserDetails details = SecurityHelper.getUserDetails(authentication);

return ResponseEntity.ok( 

            new JwtResponse(

                jwt

                details.getUser(), 

                getRoles(details.getAuthorities())));


웹 프로그램은 아래와 같이 인증후 획득한 토큰값을 저장하고 서버와 통신이 필요한 경우 헤더에 토큰 값을 포함하여 전달하게 된다. 서버 통신은 axois 를 사용하여 구현하였다. 참고로 코드 구현에 있어 UI 부분은 kendoui 를 사용하였다. ( kendoui 를 사용하려면 개발자라이선스가 필요함)

var renderTo = $('form[name=form-signin]');
var observable = new kendo.observable({
    username : null,
    password : null
});

var validator = renderTo.kendoValidator({
  errorTemplate: "

#=message#

" }).data("kendoValidator");; kendo.bind( renderTo , observable ); renderTo.submit(function(e) { e.preventDefault(); if( validator.validate() ){ kendo.ui.progress(renderTo, true); axios.post(studio.services.getApiUrl('/data/accounts/signin.json'), JSON.stringify(observable), { headers: { "Content-Type": "application/json" }} ).then(response => { const data = response.data; studio.services.accounts.loginSuccess(studio.services.accounts.state, data); window.location.replace("index.html"); }).catch(function (error) { // handle error studio.ui.handleAxiosError(error); observable.set('password', null); }) .then(function () { // always executed kendo.ui.progress(renderTo, false); }); } });

index.js
 
 const user = JSON.parse(localStorage.getItem("user"));
 const initialState = user
  ? { status: { loggedIn: true }, user }
  : { status: { loggedIn: false }, user: null };
  
 function authHeader() {
  // return authorization header with jwt token
  let _user = JSON.parse(localStorage.getItem("user"));
  if (_user && _user.jwtToken) {
    return { Authorization: "Bearer " + _user.jwtToken };
  } else {
    return {};
  }
  }

 function loginSuccess(state, data) {
  if (data.jwtToken) {
    // store user details and jwt token in local storage to keep user logged in between page refreshe
    localStorage.setItem("user", JSON.stringify(data));
  }
  state.loggedIn = true;
  state.user = data;
}

서버 호출이 필요한 경우 아래와 같은 방식으로 헤더에 jwt 포함하여 전송한다. 

sttings-locale.html 
const headers = {
  Accept: "application/json",
  "Content-Type": "application/json"
};
Object.assign(headers, studio.services.accounts.authHeader());

...

axios({
  url: studio.services.getApiUrl('/data/secure/mgmt/locale/save-or-update.json'),
  method: "post",
  data: JSON.stringify({ locale: $this.locale, timeZone: $this.timezone }),
  headers: headers
}).then(response => {
  let data = response.data;
  dialog.close();
}).catch(function (error) {
  studio.ui.handleAxiosError(error);
}).then(function () {
  // always executed
  kendo.ui.progress($('.k-dialog'), false);
});
                      

JWT 기반 인증 적용 후  경험한 문제들

  • spring security 에서 중복로그인 방지를 설정한 경우 중복 로그인 오류로 인하여 정상적인 응답을 받지 못하는 경우가 다수 발생했다. 
  • 인증된 사용자에게만 보여지는 이미지와 같은 바이너리 형식을 처리하는 경우에 어려움이 있었다. 

참고자료

Securing Spring Boot with JWTs
JWT (JSON Web Token) 이해와 활용
CORS support in Spring Framework

2018년 7월 12일

IOS 디바이스 장치의 사파리 웹 브라우저 맥에서 디버깅 하기


IOS6 이전에는 iPhone 사파리 웹 브라우져에 개발자가 웹 페이지 결함을 추적할 수 있는 Debug Console이 내장되어 있었다. 최근 IOS 버전들은 Web Inspector 를 사용하고 있다.

Web Inspector 는 IOS 디바이스 장치의 ➊사파리 설정에서 활성화할 수 있지만, Web Inspector 를 사용하려면 ➋ OS 디바이스를 케이블로 맥 컴퓨터에 연결하고 ➌ 맥의 사파리 개발자용 메뉴에서 실행할 수 있다.

➊ Web Inspector 사파리 설정에서 활성화





































➌ 맥의 사파리 개발자용 메뉴에서 실행










참고자료

How to Activate the iPhone Debug Console

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?

2016년 12월 26일

안드로이드에서 Volley 을 사용하여 HTTP 통신을 할때 타임아웃 문제

Volley 의 주요 기능중 하나는 비동기 방식의 RESTful HTTP 요청을 간단하게 구현할 수 있다는 점이다.

Transmitting Network Data Using Volley

다음의 코드를 보자. 일반적인 경우는 (2.5초 이내에 응답이 오는 경우) 문제가 없지만 서버와 통신이 지연되는 경우 안드로이드 앱이 다시 한번 서버 요청을 하게되는 것을 확인하게 될것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    RequestQueue queue = Volley.newRequestQueue(getApplicationContext());
    private void memberJoin() {
        String url = "http://test.com/data/member/join.jsp";
        StringRequest stringRequest = new StringRequest(
            Request.Method.POST, 
            url, 
            createMySuccessListener(), 
            createMyErrorListener()) {
            protected Map<StringString> getParams() throws com.android.volley.AuthFailureError {
                Map<StringString> params = new HashMap<StringString>();
                eturn params;
            }
        };
        queue.add(stringRequest);
    }
    public Response.Listener<String> createMySuccessListener() {
        return new Response.Listener<String>() {
            @Override
            public void onResponse(String response) {         
       
            }
        };
    }
    public Response.ErrorListener createMyErrorListener() {
        return new Response.ErrorListener() {
            @Override
            public void onErrorResponse(VolleyError error) {
                
            }
        };
    }
cs

이는 기본적으로 Volley 에서 서버에 요청을 보낼때는 기본정책이 적용되고 있는데 이때 응답 대기 타임아웃 값이  2500 마이크로초로 설정되어 있기 때문이다. 다음은 관련 Volley 자바 클래스이다.

  • com.android.volley.toolbox.StringRequest
  • com.android.volley.Request
  • com.android.volley.DefaultRetryPolicy 


2.5 초 이상의 응답 대기가 요구되는 경우는 아래와 같이 커스텀 정책을 생성하여 설정하면 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    RequestQueue queue = Volley.newRequestQueue(getApplicationContext());
    private void memberJoin() {
        String url = "http://test.com/data/member/join.jsp";
        StringRequest stringRequest = new StringRequest(
            Request.Method.POST, 
            url, 
            createMySuccessListener(), 
            createMyErrorListener()) {
            protected Map<StringString> getParams() throws com.android.volley.AuthFailureError {
                Map<StringString> params = new HashMap<StringString>();
                eturn params;
            }
        };
        // 커스텀 정책을 생성하여 지정한다.
        stringRequest.setRetryPolicy(new com.android.volley.DefaultRetryPolicy(
            20000 ,
            com.android.volley.DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
            com.android.volley.DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        queue.add(stringRequest);
    }
cs