2016년 11월 27일 일요일

ModelAttribute에 대해 annotation 적용하기

이전에 [annotation을 사용한 controller aop 설정하기] 란 글을 쓴 적이 있다.

class 나 method 단위에서 aop를 적용하는 방법에 대한 내용인데 parameter 단위로 aop를 적용을 할 수도 있다.

하지만 개인적으로 느끼기엔 parameter 단위의 aop적용은 그리 효율적이지 못하다.

aop 문법이 특정 타입에 대해 적용되어 있는 annotation을 적용하는 것에 대해 효과적인 명시 방법이 없다.

method parameter의 첫번째가 특정 조건일때, 끝이 특정 족건일 때, 중간에 특정 조건일 때 처럼만 선언이 가능하여 어색하고 딱히 확실한 명시 방법이 없다.
(혹 내가 못찾은 것 일 수도 있겠지만..)


또 하나의 문제는 spring의 ModelAttribute처럼 request parameter나 path variable에 대해 바인딩을 하려면 aop를 통한 처리는 복잡하고 어렵다.


이에 대해 Spring은 HandlerMethodArgumentResolver 기능을 제공한다.

넘어오는 매개 변수에 대해 supportsParameter 인 경우 응답값을 resolveArgument 메소드를 통해 전달해 줄 수 있다.


하지만 해당 구현만 하면 2가지 단점이 있다.
  1. 외부에서 넘어온 request parmaeter나 path variable의 값이 object 에 맵핑되지 않는다. (modelAttribute 기능이 적용되지 않는다.)
  2. hibernate validation 체크가 무시 된다.


따라서 스프링이 modelAttribute에 대해 어떻게 동작하는지를 살펴보는 것이 좋다.

스프링에서는 HandlerMethodArgumentResolver을 modelAttribute에 대해서 구현한 ModelAttributeMethodProcessor를 제공하며 특히 servlet을 사용하는 경우 ModelAttributeMethodProcessor를 확장한 ServletModelAttributeMethodProcessor를 제공한다.

ModelAttributeMethodProcessor는 resolveArgument 메소드가 final 선언 되어 있어 커스텀한 확장을 하지 못한다.


따라서 다음처럼 custom ModelAttributeMethodProcessor를 만들어준다.

public abstract class BlueskyHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
 
 /* (s) ServletModelAttributeMethodProcessor method copy */
 @Override
 public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
   NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
  String name = ModelFactory.getNameForParameter(parameter);
  Object attribute = (mavContainer.containsAttribute(name) ? mavContainer.getModel().get(name) :
    createAttribute(name, parameter, binderFactory, webRequest));

  if (!mavContainer.isBindingDisabled(name)) {
   ModelAttribute ann = parameter.getParameterAnnotation(ModelAttribute.class);
   if (ann != null && !ann.binding()) {
    mavContainer.setBindingDisabled(name);
   }
  }

  WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
  if (binder.getTarget() != null) {
   if (!mavContainer.isBindingDisabled(name)) {
    bindRequestParameters(binder, webRequest);
   }
   validateIfApplicable(binder, parameter);
   if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
    throw new BindException(binder.getBindingResult());
   }
  }

  // Add resolved attribute and BindingResult at the end of the model
  Map<string, object> bindingResultModel = binder.getBindingResult().getModel();
  mavContainer.removeAttributes(bindingResultModel);
  mavContainer.addAllAttributes(bindingResultModel);

  return binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);
 }

 protected Object createAttribute(String attributeName, MethodParameter methodParam,
   WebDataBinderFactory binderFactory, NativeWebRequest request) throws Exception {

  return BeanUtils.instantiateClass(methodParam.getParameterType());
 }
 
 protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest request) {
  ServletRequest servletRequest = request.getNativeRequest(ServletRequest.class);
  ServletRequestDataBinder servletBinder = (ServletRequestDataBinder) binder;
  servletBinder.bind(servletRequest);
 }
 
 protected void validateIfApplicable(WebDataBinder binder, MethodParameter methodParam) {
  Annotation[] annotations = methodParam.getParameterAnnotations();
  for (Annotation ann : annotations) {
   Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
   if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
    Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
    Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
    binder.validate(validationHints);
    break;
   }
  }
 }
 
 protected boolean isBindExceptionRequired(WebDataBinder binder, MethodParameter methodParam) {
  int i = methodParam.getParameterIndex();
  Class<?>[] paramTypes = methodParam.getMethod().getParameterTypes();
  boolean hasBindingResult = (paramTypes.length > (i + 1) && Errors.class.isAssignableFrom(paramTypes[i + 1]));
  return !hasBindingResult;
 }
 /* (e) ServletModelAttributeMethodProcessor method copy */
}


이제 custom한 annotation을 만들어 본다.


/**
 * 로그인한 유저의 Blog 정보를 주입하기 위해 사용하는 aop
 * @author bluesky
 *
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface UserBlog {

 public boolean checkBlog() default false; // 외부 접근 파라메터에 대해 유저의 blog 인지 확인 여부
}

해당 어노테이션이 적용된 modelAttribute에 대한 methodArgumentResolver를 만들어준다.

/**
 * 유저의 Blog 정보를 반환하는 Resolver
 * @author bluesky
 *
 */
public class UserBlogHandlerMethodArgumentResolver extends BlueskyHandlerMethodArgumentResolver {

 @Override
 public boolean supportsParameter(MethodParameter parameter) {
  return Blog.class.isAssignableFrom(parameter.getParameterType()) && Arrays.asList(parameter.getParameterAnnotations()).stream().anyMatch(annotation -> annotation.annotationType().isAssignableFrom(UserBlog.class));
 }

 @Override
 public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
  List<Blog> userBlogList = BlogRequestAttributeUtil.getUserBlogList();
  if (userBlogList.isEmpty()) {
   throw new BlueskyException(BlogErrorCode.NOT_EXIST_BLOG);
  }
  
  Blog requestBlog = (Blog) super.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
  
  UserBlog userBlog = (UserBlog) Arrays.asList(parameter.getParameterAnnotations()).stream().filter(annotation -> annotation.annotationType().isAssignableFrom(UserBlog.class)).findAny().get();
  if (userBlog.checkBlog()) {
   
   if (requestBlog.getId() == 0) {
    throw new BlueskyException(BlogErrorCode.NOT_EXIST_PARAMETER_BLOG_ID);
   }
   return userBlogList.stream().filter(blog -> blog.getId() == requestBlog.getId()).findAny().orElseThrow(() -> new BlueskyException(BlogErrorCode.INVALID_PARAMETER_BLOG_ID)); // 유저의 블로그가 아닌 접근인 경우 에러
  }
  
  return userBlogList.get(0);
 }
 
}


이제 해당 annotation 을 사용하면 modelAttribute가 동작하고 hibernate validation도 처리가 되는 custom methodArgumentResolver가 동작하는 것을 확인 할 수 있다.
 @PreAuthorize(AuthorizeRole.PRE_AUTHORIZE_ROLE)
 @GetMapping(value = "/{id}/article/write")
 public String writePage(@UserBlog(checkBlog = true) Blog blog, ModelMap modelMap) {
  modelMap.addAttribute(articleCategoryService.findByBlog(blog));
  return "blog/write";
 }

2016년 11월 19일 토요일

Spring Boot 1.4.x 에서 Thymeleaf 3 사용하기

Spring boot 1.4.0.RELEASE 이후 Thymeleaf 3.0을 지원하게 되었다.

기존 프로젝트를 판올림하면서 기록해본다.

1.4.x 이후에도 Spring Boot는 Thymeleaf 2에 대해 default 선언을 하고 있다.

Spring Boot Docs에는 다음과 같이 Thymeleaf 3 적용방법을 안내하고 있다.

[Use Thymeleaf 3]


이 내용은 기본적으로 사용하는 Thymeleaf 3이 적용에 대해서 안내하고 있으며 추가적인 라이브러리 들에 대해서는 언급되어 있지 않다.

다음과 같이 pom.xml에 추가한다.

<properties>
    <thymeleaf.version>3.0.0.RELEASE</thymeleaf.version>
    <thymeleaf-layout-dialect.version>2.0.0</thymeleaf-layout-dialect.version>
</dependency>

기본으로 사용하는 thymeleaf-layout-direct의 경우 boot dependencies에는 1.x대 (thymeleaf 2이하 지원)으로 선언되어 있으나 위 선언을 하면 2.x대 (thymeleaf 3 지원)으로 버전 상속된다.

추가적으로 사용하는 라이브러리에 대해 다음가 같이 추가해주어야 한다.

Java 8을 사용하는 경우 thymeleaf-extras-java8time 을 선언해서 사용해야 하며

Spring Security를 사용하는 경우 thymeleaf-extras-springsecurity4 를 선언해야한다.
(둘다 기본 설정이 Thymeleaf 2 이하로 선언되어 있다.)

다음과 같이 추가한다.

<properties>
    <thymeleaf.version>3.0.0.RELEASE</thymeleaf.version>
    <thymeleaf-layout-dialect.version>2.0.0</thymeleaf-layout-dialect.version>
    <thymeleaf-extras-springsecurity4.version>3.0.1.RELEASE</thymeleaf-extras-springsecurity4.version>
    <thymeleaf-extras-java8time.version>3.0.0.RELEASE</thymeleaf-extras-java8time.version>
</dependency>


thymeleaf 3에서는 기존과 다르게 HTML, HTML5에 대해 모드 구분이 필요없어졌다. 하지만 spring은 아직 기본으로 HTML5를 설정하고 있어 이에 대한 warning을 제거하려면 다음과 같이 application.properties에 선언해준다. (꼭 해야할 필요는 없다.)
spring.thymeleaf.mode=HTML

2016년 11월 14일 월요일

2016년 10월 25일 화요일

MyBatis Java 8 관련 TypeHandler 설정 불필요

일전에 'Spring Boot war에서 MyBatis 사용시 TypeHandler scan 오류가 발생하는 현상' 이라는 글을 이곳에 썼었다.

시간이 지나 해당 글의 문제에 대한 패치가 되었다.

mybatis 3.4.0 이후 사용자는 더이상 java 8의 typehandler를 따로 선언할 필요가 없이 아래 dependeny만 추가하면 알아서 typehandler가 등록된다.
mybatis-typehandlers-jsr310

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-typehandlers-jsr310</artifactId>
    <version>1.0.1</version>
</dependency>

또한 boot war에서 mapper xml을 찾지못해 추가로 선언하던 SpringBootVFS 설정도 더 이상 하지 않아도 된다. (이 부분에 대한 릴리즈/패치 노트는 찾지 못했다.)

2016년 10월 17일 월요일

spring DispatcherServlet이 강제하는 이름들

boot를 사용하다보면 빈 생성을 참 편하게 할 수 있어 좋다.

ContitionalOnMissingBean 만 확인하고 개별 구현하고 연계처리를 별달리 하지 않아도 알아서 가져가서 써주니 편하다.

하지만 이따금 안되는 케이스들이 있는데 이번에 겪은 경우가 그랬다.

CookieLocaleResolver를 별도로 구현할 일이 있어 생성한 후 빈선언을 해주었는데 디버그를 해보니 acceptHeaderLocaleResolver (기본 LocaleResolver)을 사용하는 것이다.

boot의 설정상으론 해당 빈이 있는 경우 기본 LocaleResolver를 생성하지 않도록 되어 있는데 어째서 그런 것인가 살펴보니 boot가 아닌 spring mvc의 dispatcherServlet이 강제로 bean name을 고정해서 사용하고 있었다.

기본적으로 1개만 등록하는 빈들에 대해서 강제한 사항인 듯 싶다.

다음과 같은 이름들이 고정된 이름들이다.

  • multipartResolver
  • localeResolver
  • themeResolver
  • handlerMapping
  • handlerAdapter
  • handlerExceptionResolver
  • viewNameTranslator
  • viewResolver
  • flashMapManager


이런 빈들에 대해서는 다음과 같이 이름을 지정하고 사용해야 한다.
@Component("localeResolver")
public class MyLocaleResolver extends CookieLocaleResolver {
    // 구현
}

2016년 10월 13일 목요일

int array 를 list 로 변환하기

array 를 list로 변환할 때 보통 다음과 같이 사용한다.
String[] strings = new String[] {"a", "b", "c"};
List<string> stringList = Arrays.asList(strings);

이 변환은 non primitive 타입에 대해서만 가능한 변환이다.

만약 primitive 타입인 int array를 Arrays.asList를 사용하면 반환값은 다음과 같다.
int[] ints = {1, 4, 3};
List<int[]> intList = Arrays.asList(ints);

즉 Arrays.asList를 통해 primitive 타입을 list로 변환을 할 수 없다.

다음과 같이 변환해야 한다.
int[] ints = {1, 4, 3};
List<Integer> intList = Arrays.stream(ints).boxed().collect(Collectors.toList());


2016년 9월 30일 금요일

annotation을 사용한 controller aop 설정하기

annotation을 이용하면 전역 처리가 편리하다.

아래와 같이 어노테이션을 선언했다.


@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ROLE_USER')")
public @interface PreAccountAuthorize {
 boolean checkBanInfo() default false; //제재 여부 체크
}

해당 타겟이 annotation type이며 실행 시 annotation이 사라지지 않기 위해 RetentionPolicy는 runtime 으로 지정되어 있다.

해당 어노테이션을 사용하는 부분이 class와 method 라는 선언을 하였다.

기존 인증 체크에 추가적인 조건 체크를 더하기 위해 기존 인증 체크를 추가하였다.
@PreAuthorize("hasRole('ROLE_USER')")

사실 @PreAuthorize를 선언하면 해당 어노테이션에 위 선언들이 있기 때문에 아래와 같이 써도 무방하다.
@PreAuthorize("hasRole('ROLE_USER')")
public @interface PreAccountAuthorize {
   boolean checkBanInfo() default false; //제재 여부 체크
}
@Slf4j
@Aspect
@Component
public class PreAccountAuthorizeAspect {
 
   @Setter private BanInfoService banInfoService;

   @Before("@annotation(preAccountAuthorize)")
   public void methodBefore(JoinPoint joinPoint, PreAccountAuthorize preAccountAuthorize) {
      checkBefore(joinPoint, preAccountAuthorize);
    }
 
    @Before("@target(preAccountAuthorize) && bean(*Controller)")
    public void classBefore(JoinPoint joinPoint, PreAccountAuthorize preAccountAuthorize) {
        checkBefore(joinPoint, preAccountAuthorize);
    }
 
    public void checkBefore(JoinPoint joinPoint, PreAccountAuthorize preAccountAuthorize) {
        if (banInfoService == null) {
            throw new BlueskyException("banInfoService is not set");
        }
        checkBanInfo(preAccountAuthorize);
    }
 
    private void checkBanInfo(PreAccountAuthorize preAccountAuthorize) {
        log.debug("checkBanInfo");
        if (!preAccountAuthorize.checkBanInfo()) {
            return;
        }
        List banInfo = banInfoService.checkBanInfo();
        log.debug("checkBanInfo banInfo : {}", banInfo);
        if (!banInfo.isEmpty()) {
            throw new BlueskyException("security.BANNED_ACCOUNT");
        }
    }
}

@target 을 이용한 class annotation은 전체 bean을 전부 훝어서 해당 어노테이션을 체크하는 듯 하다.
이 경우 spring security 를 사용하면 GlobalMethodSecurityConfiguration 관련해서 에러가 발생한다.

aop 관련 동작을 전체 bean 대상으로 체크하다 security쪽 bean의 non-visible class를 대상으로 cglib 동작이 수행되면서 에러가 발생하는데 이를 막기 위해 controller로 제한하였다.
관련한 에러는 다음과 같다.

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration': Unsatisfied dependency expressed through method 'setObjectPostProcessor' parameter 0: Error creating bean with name 'objectPostProcessor' defined in class path resource [org/springframework/security/config/annotation/configuration/ObjectPostProcessorConfiguration.class]: Initialization of bean failed; nested exception is org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class [class org.springframework.security.config.annotation.configuration.AutowireBeanFactoryObjectPostProcessor]: Common causes of this problem include using a final class or a non-visible class; nested exception is java.lang.IllegalArgumentException: Cannot subclass final class org.springframework.security.config.annotation.configuration.AutowireBeanFactoryObjectPostProcessor; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'objectPostProcessor' defined in class path resource [org/springframework/security/config/annotation/configuration/ObjectPostProcessorConfiguration.class]: Initialization of bean failed; nested exception is org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class [class org.springframework.security.config.annotation.configuration.AutowireBeanFactoryObjectPostProcessor]: Common causes of this problem include using a final class or a non-visible class; nested exception is java.lang.IllegalArgumentException: Cannot subclass final class org.springframework.security.config.annotation.configuration.AutowireBeanFactoryObjectPostProcessor

Spring Boot 1.4.1-RELEASE에서 spring-data-jpa의 auditing 이 동작하지 않는 문제 발생

https://github.com/spring-projects/spring-boot/issues/6983

현재 Spring Boot 1.4.1-RELEASE를 사용하면 spring-data-jpa의 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 같은 auditing 처리가 제대로 동작하지 않는 문제가 있다.
orm.xml을 로드하는 boot의 JpaBaseConfiguration 설정에 버그가 있다.

임시 해결책으로 아래의 빈을 선언해서 쓰는 방법이 있다.


@Bean
public EntityManagerFactoryBuilder entityManagerFactoryBuilder(JpaProperties properties, JpaVendorAdapter jpaVendorAdapter, ObjectProvider persistenceUnitManagerProvider) {
 return new EntityManagerFactoryBuilder(jpaVendorAdapter, properties.getProperties(), persistenceUnitManagerProvider.getIfAvailable(), null);
}

1.4.2 가 나오기 전까진 당분간 저 선언을 유지해야 한다.




1.4.2-RELEASE가 나온 이후 해당 버그는 수정되었다.

Spring Boot 1.4.2-RELEASE

2016년 9월 28일 수요일

thymeleaf 에서 spring security login 여부를 변수로 사용하기

spring security 정보를 thymeleaf 에서 사용하는 기본적인 방법은 다음과 같다.

thymeleaf-extras-springsecurity4 를 사용하고 다음과 같이 호출한다.
<div sec:authorize="hasRole('ROLE_ADMIN')">
    This content is only shown to administrators.
</div>
<div sec:authorize="hasRole('ROLE_USER')">
    This content is only shown to users.
</div>

이와 같이 사용하면 영역별로 처리하기 편하지만 영역을 나누기 싫은 경우가 있다.

이럴 때엔 thymeleaf의 변수로 사용하는 것이 편하다.

다음과 같이 사용이 가능하다.

<div th:with="isLogin = ${#authorization.expression('isAuthenticated()')}">
[[${isLogin}]]
    </div>

일반적으로 위와 같이 사용하면 아무 문제가 없다.
하지만 security가 적용되지 않은 요청 범위의 페이지(예를 들면 /error/...와 같은 에러 페이지)를 호출할 경우
Caused by: java.lang.IllegalArgumentException: Authentication object cannot be null
에러가 발생한다.

spring security를 통해 authorization이 생성되지 않은 상태에서 expression을 호출하기 때문인데 이를 방지하기 위해 아래처럼 선언하는 것이 좋다.

<div th:with="isLogin = ${#authorization.getAuthentication() != null and #authorization.expression('isAuthenticated()')}">
[[${isLogin}]]
</div>

2016년 9월 5일 월요일

html에서 bindException 결과 처리 시 순서 지정하기

사용자에게 입력을 받은 데이터의 정규식 체크가 올바른가에 대해 기존에는 client, server 양쪽다 체크해야 한다고 생각한 때가 있다.

요즘은 에러에 대해 공통화 처리로 개발을 하면서 굳이 client에서 체크할 필요가 있을까 싶다.

bindException의 경우 array 형태로 에러 결과를 반환한다.

{
    "result": [{
        "errorCode": null,
        "exceptionClassName": "bindException",
        "message": "소개글을 입력해주세요.",
        "object": "fooClass",
        "field": "intro",
        "displayableMessage": true
    }, {
        "errorCode": null,
        "exceptionClassName": "bindException",
        "message": "이미지를 선택해주세요.",
        "object": "fooClass",
        "field": "imgSrc",
        "displayableMessage": true
    }, {
        "errorCode": null,
        "exceptionClassName": "bindException",
        "message": "길드이름을 입력해주세요.",
        "object": "fooClass",
        "field": "guildName",
        "displayableMessage": true
    }, {
        "errorCode": null,
        "exceptionClassName": "bindException",
        "message": "서버를 선택해주세요.",
        "object": "fooClass",
        "field": "serverId",
        "displayableMessage": true
    }, {
        "errorCode": null,
        "exceptionClassName": "bindException",
        "message": "내용을 입력해주세요.",
        "object": "fooClass",
        "field": "contents",
        "displayableMessage": true
    }]
}


각각의 에러를 표시하는 위치가 개별로 존재한다면 별 문제가 없지만 특정 단일 영역에 저 많은 에러들 중 우선순위를 정해 하나만 표시하고 싶은 경우 순서가 중요해진다.

문제는 bindException은 순서를 지정하는 기능이 없다는 점이다.

이에 대해서 javascript의 sort 처리로 순서를 정하는 식으로 해결을 하였다.

function sortFunc(a, b, targetKey, targetArray) {
    var aCodeIndex = $.inArray(a[targetKey], targetArray);
    var bCodeIndex = $.inArray(b[targetKey], targetArray);
    if (aCodeIndex > -1 && bCodeIndex > -1) {
        return aCodeIndex > bCodeIndex;
    } else if (aCodeIndex > -1) {
        return false;
    } else if (bCodeIndex > -1) {
        return true;
    } else {
        return a[targetKey] > b[targetKey];
    }
}

function errorMessageSort(a, b) {
    var sortKeyOrder = ["serverId", "guildName", "intro", "imgSrc", "contents", "phoneNumber"];
    return sortFunc(a, b, "field", sortKeyOrder);
}

$.ajax({
    url : "[호출주소]",
    type : "POST",
    data : [데이터파라메터]
}).done(function(data) {
    //성공한 경우 처리
}).fail(function(jqXHR, textStatus, errorThrown) {
    jqXHR.responseJSON.result.sort(errorMessageSort);
 
    $("[메세지를 보여줄 영역]").text(jqXHR.responseJSON.result[0].message).show();
}

2016년 8월 31일 수요일

sonatype nexus 3.0

nexus 가 드디어 3.0이 나왔다.

설치해서 실행해보면 확 달라진 ui를 맛볼 수 있다.

[Sonatype Releases Nexus Repository 3.0]

가장 크게 달라진 점은 docker registry, nuget, bower, npm등 다양한 registry를 지원하는 점이다.

또한 gui도 변했다.

tree 형태로 열거해서 보던 방식에서 search를 하는 형태로 변경되었는데 초기엔 불편할 수 있지만 성능상 이점이 더 많을 것 같다.

2016년 8월 30일 화요일

spring mvc의 exceptionHandler에서 redirect 할 경우 주의해야할 점

spring 의 exceptionHandler를 사용하는 경우 보통 다음과 같은 형태이다.
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ModelAndView handleError(SomeException exception) {
        ExceptionMessage exceptionMessage = new ExceptionMessage();
        // exception 결과 표시용 객체 처리
        return new ModelAndView("/error", exceptioMessage);
    }
}

응답 결과를 redirect 하는 경우는 redirect: 를 사용한다.

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ModelAndView handleError(SomeException exception) {
        ExceptionMessage exceptionMessage = new ExceptionMessage();
        // exception 결과 표시용 객체 처리
        return new ModelAndView("redirect:/error", exceptioMessage);
    }
}
그런데 이 경우 redirect가 동작하지 않는 문제점이 있다.

이유는 바로 @ResponseStatus 어노테이션 때문이다.

리다이렉트 처리는 302로 응답을 내려보내고 다시 redirect할 주소로 이동을 해야하는데 @ResponseStatus가 명시되어 있으면 해당 status 로 응답이 처리되어 redirect가 동작하지 않는다.

따라서 redirect를 해야할 경우 status는 메소드 내의 view의 status 선언으로 처리를 해야한다.

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler
    //@ResponseStatus(HttpStatus.BAD_REQUEST)
    public ModelAndView handleError(SomeException exception) {
        ExceptionMessage exceptionMessage = new ExceptionMessage();
        // exception 결과 표시용 객체 처리
        ModelAndView modelAndView = new ModelAndView("redirect:/error", exceptioMessage);
        return modelAndView;
    }
}


html, json 요청에 따라 redirect html, json response 처리를 하는 것은 이전 글을 참조하면 된다.
[ spring mvc의 exceptionHandler에서 mediaType 정보 획득하기 ]

만약 동일 Exception에 대해 status 처리를 분기하고 싶은 경우 (또는 위처럼 redirect가 있거나 exception status 처리를 나눠야 하는 경우)는 다음과 같이 처리를 한다.

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler
    //@ResponseStatus(HttpStatus.BAD_REQUEST)
    public ModelAndView handleError(SomeException exception, HttpServletResponse response) {
        ExceptionMessage exceptionMessage = new ExceptionMessage();
        if (리다이렉트의 경우) {
            // exception 결과 표시용 객체 처리
            ModelAndView modelAndView = new ModelAndView("redirect:/error", exceptioMessage);
            return modelAndView;
        }
        // http status코드를 내려야하는 경우
        ModelAndView modelAndView = new ModelAndView(PAGE_ERROR, resultMap);
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        return modelAndView;
    }
}


ModelAndView의 setStatus 메소드를 사용하는 것이 exception 처리에서는 제대로 동작하지 않는 듯 하다.

2016년 8월 18일 목요일

Spring Boot에서 header의 accept language를 이용한 i18n 처리

서버 Locale 설정


스프링에서 web을 사용하면 LocaleContextHolder를 통한 Locale 관리가 이루어진다.

이 LocaleContext에 Locale을 설정하기 위해 spring에서는 LocaleResolver를 제공한다.

spring에서는 LocaleResolver의 구현체를 4가지 제공하는데 아래와 같다.

  • FixedLocaleResolver
  • SessionLocaleResolver
  • CookieLocaleResolver
  • AcceptHeaderLocaleResolver

Spring Boot의 WebMvcProperties는 위 4가지중 Fixed와 AcceptHeader에 대한 설정을 지원하며 기본 설정은 acceptHeader이다.

다만 기본 설정으로 사용하는 경우 AcceptHeaderLocaleResolver의 supportedLocale 설정에 대한 WebMvcProperties의 설정 지원이 없기 때문에 모든 Locale을 받아들이게 된다.

만약 특정 Locale만 처리가 가능하도록 원한다면 별도로 bean을 선언해주는 것이 좋다.
@Bean
public LocaleResolver localeResolver() {
    AcceptHeaderLocaleResolver acceptHeaderLocaleResolver = new AcceptHeaderLocaleResolver();
    acceptHeaderLocaleResolver.setDefaultLocale(Locale.getDefault());
    List localeList = new ArrayList<>();
    localeList.add(Locale.KOREAN);
    localeList.add(Locale.CHINESE);
    localeList.add(Locale.ENGLISH);
    acceptHeaderLocaleResolver.setSupportedLocales(localeList);
    return acceptHeaderLocaleResolver;
}


restTemplate의 Locale 설정

restTemplate에서 요청하는경우 accept-language header에 locale 정보를 담아 보내면 된다.

accept-language 를 header에 담기 위해 쓸 수 있는 HttpHeaderInterceptor가 있다
이 interceptor를 restTemplate에 선언하면 client에서 오는 header정보를 그대로 담아 전달한다.

다만 이렇게 쓰는 경우 localeResolver를 통해 변경한 locale은 담을 수 없다.

localeResolver를 통해 변경된 locale은 response header의 content-language 정보이기 때문에 request header의 accept-language를 전달하는 HttpHeaderInterceptor가 아닌 별도의 Interceptor를 구현해야한다.


/**
 * Locale별 응답 에러 메세지처리를 위한 interceptor
  *
 */
public class AcceptLanguageClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        request.getHeaders().set(HttpHeaders.ACCEPT_LANGUAGE, LocaleContextHolder.getLocale().toLanguageTag());
        return execution.execute(request, body);
    }
}
주의 해야할 점은 header에 보낼 locale정보는 toLanguageTag 메소드로 보내야 한다는 점이다.

java에서 쓰이는 Locale은 ko_KR과 같은 형태이지만 request header의 accept-langauge의 형태는 ko-kr 과 같은 형태이기 때문에 header에서 쓰이는 형태로 전송하지 않으면 정상적으로 language를 전달받지 못한다.


2016년 8월 10일 수요일

restTemplate에서 get 요청시 필요한 query parameter 를 domain Object에서 추출하기

restTemplate 사용시 post나 put은 request body에 object를 json으로 전달하여 중간에 변수 설정하는 단계를 생략하고 사용하기가 편하다.

하지만 get이나 delete는 body가 아닌 query parameter로 값을 전송하는데 일일이 명시해야 하는 번거로움이 있다.
restTemplate.getForObject("호출도메인/user/{id}", User.class, id)

간단하게 id를 기준으로 호출하는 형태면 별 문제가 없지만 검색 처리와 같이 많은 query parameter를 사용하는 형태의 경우 일일이 명기해서 쓰면 추후 parameter가 변경될 때마다 변경해야할 번거로움이 크다.

그렇다고 post나 put처럼 request body로 전송하는 것도 올바른 처리는 아닌 것 같고 단순히 object를 query parameter로 쉽게 바꿀 수 있는 방법이 없을까 싶어 처리를 해보았다.

우선 검색을 위한 파라메터를 모아둔 object를 만든다.

@Data
public class Search {
 
 @Min(value = 1, groups = Search.class)
 private int page;
 
 @Range(min = 1, max = 200, groups = Search.class)
 private int pageSize;
 
 @NotNull(groups = Search.class)
 private String searchKeyword;
 
 @NotNull(groups = Search.class)
 private String sortColumn;
 
 @NotNull(groups = Search.class)
 private String sortOrder;
 
 public interface Search {};
}


해당 object를 이용해 검색을 위한 query parameter를 만들고 restTemplate을 호출한다.
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("호출도메인/board/search/findBySearch");
MultiValueMap params = new LinkedMultiValueMap<>();
params.setAll(objectMapper.convertValue(search, Map.class));
builder.queryParams(params);
Article[] articles = restTemplate.getForObject(builder.build().toUri(), Article[].class);

구현은 다음과 같다.

  • UriCompomentsBuilder를 통해 queryParameter까지 생성을 한다.
  • Object mapper를 통해 object를 map으로 변환한 후 해당 map의 내용을 MultiValueMap에 담은 후 uriCompomentBuilder에 전달한다.

이렇게 사용하면 get요청에 parameter가 많은 경우 쉽게 사용할 수 있다.
하지만 단일 domain object만 정상적으로 사용이 가능하고 sub domain을 가지고 있는 경우는 query parameter가 의도된 대로 생성되지 않는다.

간단히 사용할 케이스에만 사용하면 좋지 않을까 싶다.

SpringOne Platform 2016 SlideShare

http://www.slideshare.net/SpringCentral/presentations

SpringOne Platform 2016 SlideShare 가 공개되었다.

2016년 8월 5일 금요일

SonarQube 6.0 Release

SonarQube 6.0 이 2016년 8월 4일 공개되었다.

[Release Note]

눈에 띄는 변경점은 아래와 같다.

  • Elasticsearch가 1.7.5에서 2.3.x로 변경
  • Scanner 관련 수행 시간 표시
  • Quality Profiles,  Rules, Permissions 설정 개선
  • Project Creator 기능 추가 (프로젝트 만들때 반복적인 설정 template 화 하는 기능인 듯.)


8/5일 현재 docker registry에는 아직 6.0은 올라와 있지 않다.

SonarQube Scanner 사용하기

jenkins에서 sonarqube를 사용하는 방법이 sonarqube 4.x 이후 scanner가 나오면서 변경되었다. (중간에 sonar-runner 라는 이름이었다가 scanner로 변경됨)

sonarqube 3.x 버전에서는maven의 sonar:sonar 명령어를 통해 실행하였는데 maven을 통한 실행이라 모듈형 프로젝트의 실행은 편한 장점은 있지만 상당히 느렸다.

scanner는 단독 실행하기 때문에 maven을 경유하는 비용이 없어졌다.

대신 scanner에서 실행할 수 있는 기준을 명시한 sonar-project.properties를 프로젝트에 추가해야한다.

기본적인 사용방법은 해당 사이트의 document에서 확인할 수 있다.
[SonarQube Scanner document]

multi module 형태의 maven 프로젝트의 경우 각 모듈에 대한 선언을 해주어야 한다.
이 때 pom 타입의 packaging은 대상에서 제외하여야 한다.
대략 아래와 같이 선언하면 된다.

sonar.projectKey=bluesky-project
sonar.projectName=bluesky-project
sonar.projectVersion=0.0.1-SHAPSHOT

sonar.sources=.
sonar.java.source=1.8
sonar.sourceEncoding=UTF-8
sonar.exclusions=**/generated-sources/*,**/test/*
sonar.test.exclusions=**/test/*

sonar.modules=bluesky-core,bluesky-test,\
bluesky-opensource-jdbc,\
bluesky-opensource-data-jpa,\
bluesky-opensource-data-mongodb,\
bluesky-opensource-security,\
bluesky-opensource-boot-autoconfigure,\
bluesky-app-user,\
bluesky-app-blog,\
bluesky-app-bookkeeping,\
bluesky-app-domain,\
bluesky-app-api-battlenet,\
bluesky-web-default
#bluesky-parent,\
#bluesky-opensource,\
#bluesky-app,\
#bluesky-web,\

bluesky-core.sonar.projectBaseDir=bluesky-parent/bluesky-core
bluesky-test.sonar.projectBaseDir=bluesky-parent/bluesky-test
#bluesky-opensource.sonar.projectBaseDir=bluesky-parent/bluesky-opensource
bluesky-opensource-jdbc.sonar.projectBaseDir=bluesky-parent/bluesky-opensource/bluesky-opensource-jdbc
bluesky-opensource-data-jpa.sonar.projectBaseDir=bluesky-parent/bluesky-opensource/bluesky-opensource-data-jpa
bluesky-opensource-data-mongodb.sonar.projectBaseDir=bluesky-parent/bluesky-opensource/bluesky-opensource-data-mongodb
bluesky-opensource-security.sonar.projectBaseDir=bluesky-parent/bluesky-opensource/bluesky-opensource-security
bluesky-opensource-boot-autoconfigure.sonar.projectBaseDir=bluesky-parent/bluesky-opensource/bluesky-opensource-boot-autoconfigure
#bluesky-app.sonar.projectBaseDir=bluesky-parent/bluesky-app
bluesky-app-user.sonar.projectBaseDir=bluesky-parent/bluesky-app/bluesky-app-user
bluesky-app-blog.sonar.projectBaseDir=bluesky-parent/bluesky-app/bluesky-app-blog
bluesky-app-bookkeeping.sonar.projectBaseDir=bluesky-parent/bluesky-app/bluesky-app-bookkeeping
bluesky-app-domain.sonar.projectBaseDir=bluesky-parent/bluesky-app/bluesky-app-domain
bluesky-app-api-battlenet.sonar.projectBaseDir=bluesky-parent/bluesky-app/bluesky-app-api-battlenet
#bluesky-web.sonar.projectBaseDir=bluesky-parent/bluesky-web
bluesky-web-default.sonar.projectBaseDir=bluesky-parent/bluesky-web/bluesky-web-default

2016년 7월 28일 목요일

Spring Boot 1.4.0 적용시 변경되는 부분들

[new-and-noteworthy 링크]


  • Hibernate 4 -> 5
  • org.jadira.usertype 4.0.0.GA -> 5.0.0.GA

@ConfigurationProperties deprecated attribute


Deprecated 예정인 항목 중
The locations and merge attributes of the @ConfigurationProperties annotation have been deprecated in favor of directly configuring the Environment.
이 부분이 있다.

@ConfigurationProperties를 통해 yml설정을 사용하던 부분이 앞으로 locations 가 제거되고 나면 문제가 될 듯 하다.
[관련 링크]
@PropertySource는 yml 호출을 아직 지원하지 않고 있다.


MessageSourceAutoConfiguration classpath

그동안 몰랐었는데 Spring Boot MessageSourceAutoConfiguration가 변경된 부분이 있다. [관련 링크]
1.3.2, 1.2.9 이전의 경우
private Resource[] getResources(ClassLoader classLoader, String name) {
    try {
        return new SkipPatternPathMatchingResourcePatternResolver(classLoader).getResources("classpath*:" + name + "*.properties");
    }
    catch (Exception ex) {
        return NO_RESOURCES;
    }
}


1.3.2, 1.2.0 이후
private Resource[] getResources(ClassLoader classLoader, String name) {
    try {
        return new PathMatchingResourcePatternResolver(classLoader).getResources("classpath*:" + name + ".properties");
    }
    catch (Exception ex) {
        return NO_RESOURCES;
    }
}

어차피 i18n 적용을 하면 이 변경은 의미가 없는 것 같고 오히려 한 개라도 일치하는 messageSource properties가 없으면 전체 i18n properties를 다 로드하지 않는 문제가 있다.

Hibernate @GeneratedValue

Hibernate 가 4에서 5로 변경되면서 기존 @GeneratedValue의 사용에 오류가 발생한다.

기본 strategy인 GenerationType.AUTO가 5에서 제대로 동작하지 않기 때문에 명시적으로 선언이 필요하다.

@GeneratedValue(strategy = GenerationType.IDENTITY)


Thymeleaf3

Thymeleaf 3을 쓰는 경우 thymeleaf-extras-java8time 2.1.0.RELEASE -> 3.0.0.RELEASE로 변경해서 사용해야 한다.
템플릿 엔진이 변경되면서 주석처리에 문제가 있던 부분도 해결이 되었다.
따라서 thymeleaf-extras-conditionalcomments 라이브러리는 제거해야 한다.

2016년 7월 20일 수요일

restTemplate 이 xml로 요청을 하는 경우

다음과 같은 경우 restTemplate 요청을 xml로 한다.
  1. jackson-dataformat-xml를 의존성으로 가지고 있다.
  2. new RestTemplate(); 으로 restTemplate을 생성한다.
  3. HttpMessageConvertersAutoConfiguration을 사용해서 messageConverters를 구현하지 않는다.
이 경우 RestTemplate의 생성자에 설정된 messageConverter 추가 규칙대로 converter를 호출하여 사용하게 된다.

public RestTemplate() {
    this.messageConverters.add(new ByteArrayHttpMessageConverter());
    this.messageConverters.add(new StringHttpMessageConverter());
    this.messageConverters.add(new ResourceHttpMessageConverter());
    this.messageConverters.add(new SourceHttpMessageConverter());
    this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());

    if (romePresent) {
        this.messageConverters.add(new AtomFeedHttpMessageConverter());
        this.messageConverters.add(new RssChannelHttpMessageConverter());
    }

    if (jackson2XmlPresent) {
        this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter());
    }
    else if (jaxb2Present) {
        this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
    }

    if (jackson2Present) {
        this.messageConverters.add(new MappingJackson2HttpMessageConverter());
    }
    else if (gsonPresent) {
        this.messageConverters.add(new GsonHttpMessageConverter());
    }
}


이 경우 jackson xml messageConverter가 json 보다 우선 추가가 되며 restTemplate 호출 시 HttpMessageConverterExtractor의 extractData 메소드에서 추가된 순서대로 converter를 찾아 canRead(Class clazz, MediaType mediaType) 호출이 true인 messageConverter를 사용하게 된다.

restTemplate을 별다른 설정없이 호출해서 사용하게 되면 mediaType은 null로 없다.

대부분의 messageConverter는 생성자에서 자신이 지원하는 mediaType을 선언하여 mediaType에 대한 제한을 두지만 MappingJackson2XmlHttpMessageConverter는 mediaType이 없는 경우도 canRead에서 true로 응답한다. (다르게 말하자면 제한을 두지 않는다.)

이에 대해 spring boot의 HttpMessageConverters 생성 시 reorderXmlConvertersToEnd 메소드를 통해 AbstractXmlHttpMessageConverter이나 MappingJackson2XmlHttpMessageConverter인 경우는 converters 리스트의 가장 맨뒤로 위치시키는 처리를 하여 가장 나중에 체크 되도록 하였다.

따라서 MappingJackson2XmlHttpMessageConverter에 의존성이 있으며 boot 의 autoConfiguration을 통한 생성을 하지 않은 new RestTemplate을 사용할 경우엔 converter에서 MappingJackson2XmlHttpMessageConverter를 제거하거나 아니면 HttpMessageConvertersAutoConfiguration을 통해 생성된 HttpMessageConverters를 constructor를 통해 넘겨주면 된다.


@Autowired
private HttpMessageConverters messageConverters;

new RestTemplate(messageConverters.getConverters());


spring boot를 사용하지 않고 restTemplate을 쓰는 경우 xmlConveter를 맨 뒤로 보내는 HttpMessageConverters의 reorderXmlConvertersToEnd method를 참고하면 된다.
private void reorderXmlConvertersToEnd(List> converters) {
    List> xml = new ArrayList>();
    for (Iterator> iterator = converters.iterator(); iterator.hasNext();) {
        HttpMessageConverter converter = iterator.next();
        if ((converter instanceof AbstractXmlHttpMessageConverter) || (converter instanceof MappingJackson2XmlHttpMessageConverter)) {
            xml.add(converter);
            iterator.remove();
        }
    }
    converters.addAll(xml);
}


restTemplate에서 xmlConverter를 안 쓴다면 restTemplate에서 해당 converter를 제거하는 방법도 있다.
RestTemplate restTemplate = new RestTemplate();
restTemplate.getMessageConverters().removeIf(messageConverter -> messageConverter instanceof MappingJackson2XmlHttpMessageConverter);


MessageConverter를 사용한다면 되도록 boot에서 bean으로 등록해준 messageConverter를 사용하는게 좋다

2016년 7월 15일 금요일

restTemplate의 Collection 응답 처리 방법

restTemplate으로 Collection 을 요청하는 경우 주의해야 할 점이 있다.


리턴 값 자체를 List, Set, Map으로 하는 경우 빈 값이 전달되면 restTemplate의 리턴 값은 null 값으로 처리된다.

Collection을 사용할 땐 null 보단 빈 Collection으로 리턴되는 것이 좋다.

따라서 아래와 같이 쓰는 게 좋다.

객체[] objs = getRestTemplate().getForObject("호출주소", 객체[].class);
List<객체> objList = Arrays.asList(objs);


이렇게 사용하는 경우 objList는 unmodifiable 객체가 되어 add나 remove가 불가능하다. 만약 add, remove를 원한다면 다음과 같이 선언한다.
List<객체> objList = new ArrayList<>(Arrays.asList(objs));


2016년 7월 13일 수요일

Spring MVC에서 요청 처리 시 @RequestBody는 어떻게 쓰는게 좋은가

@RequestBody의 동작

Spring MVC Controller에서 요청을 object로 binding할 때 @RequestBody 라는 어노테이션을 제공한다.
이 어노테이션의 기능은 다음과 같다.

  • @RequestBody를 사용하지 않은 경우 : query parameter, form data를 object에 맵핑한다.
  • @RequestBody를 사용하는 경우 : body에 있는 data를 HttpMessageConverter를 이용해 선언한 object에 맵핑한다.


post form data 전송은 다음과 같은 모양이다.
[head 영역]
-------
키1=데이터1&키2=데이터2


@RequestBody를 사용하는 경우는 body영역의 모양을 json과 같이 converter가 변환할 수 있는 형태로 전달해야하며 다음과 같은 모양이다.
[head 영역]
-------
{ "키1" : "데이터1", "키2" : "데이터2" }

@RequestBody를 사용하는 경우 두번째 형태의 body 데이터를 처리할 수 있다.

jquery 요청시

jquery로 요청하는 경우 POST나 PUT 요청은 기본이 form data 요청이다.
json을 변수로 선언해도 jquery는 form data 형태로 변경하여 요청을 한다.

var parameter = {};
parameter.key1 = "value1";
parameter.key2 = "value2";
$.ajax({
    url : "요청url",
    type : "POST",
    data : parameter
});


만약 json body로 요청을 하고 싶은 경우 다음과 같이 요청한다.
var parameter = {};
parameter.key1 = "value1";
parameter.key2 = "value2";
$.ajax({
    url : "요청url",
    type : "POST",
    contentType: "application/json; charset=utf-8",
    data : JSON.stringify(parameter)
});


restTemplate 요청 시


전통적인 방식의 form submit은 Controller에서 @RequestBody를 사용하지 않아야 올바르게 맵핑이 된다.
post form data submit은 다음과 같은 모양으로 요청을 한다.

위 형태로 요청하는 것을 restTemplate으로 구현할 땐 MultiValueMap으로 해당 data를 선언하여 전달한다.

MultiValueMap map = new LinkedMultiValueMap();
map.add("키1", "데이터1");
map.add("키2", "데이터2");
String result = restTemplate.postForObject("호출 주소", map, String.class);

하지만 중간에 map에 데이터를 선언하는 처리가 들어가면 불편한 점이 많다.

만약 @RequestBody로 요청을 받는 경우 단순히 아래와 같이 간단하게 사용이 가능하다.

String result = restTemplate.postForObject("호출 주소", 대상object, String.class);

언제 써야할까?

GET 이나 DELETE 요청은 body에 데이터를 담지 않는다.
따라서 @RequestBody를 사용하지 않는 것이 좋다.
@RequestBody를 사용하지 않는 경우에도 Spring MVC는 기본적으로 GET과 DELETE 요청시 form data처리를 하지 않는다.
간혹 요청 시 필요한 파라메터가 많은 경우 object에 바인딩을 하고 사용하기 위해 @RequestBody로 요청으로 사용하는 경우가 있지만 개인적으로 원래 사용방식을 따르는 형태가 좋지 않을까 싶다.

angular.js나 backbone.js같은 mv? javascript 라이브러리를 사용하는 경우 기본으로 json 포맷의 requestBody를  전송한다.
이런 경우는 @RequestBody로 요청을 처리하는게 좋다.

restTemplate 요청은 아무래도 form data 전송보다 json body형태가 훨씬 간결하고 유지보수하기 편하다.

대략 정리하면 다음과 같은 전반적인 규칙으로 사용을 하면 좋지 않을까 싶다.


Case @RequestBody 사용 여부 description
GET 요청 (brower) X query parameter 사용
POST 요청 (brower) X form data 사용
PUT 요청 (brower) X form data 사용
DELETE 요청 (brower) X query parameter 사용
GET 요청 (javascsript mv? library) X query parameter 사용
POST 요청 (javascsript mv? library) O
PUT 요청 (javascsript mv? library) O
DELETE 요청 (javascsript mv? library) X query parameter 사용
GET 요청 (restTemplate) X query parameter 사용
POST 요청 (restTemplate) O
PUT 요청 (restTemplate) O
DELETE 요청 (restTemplate) X query parameter 사용

2016년 7월 8일 금요일

spring boot mvc에서 java 8 date 객체 json 처리하기

spring mvc를 사용하면 date 객체를 주고받는 형태에 대해 고민을 하게 된다.

특히 java 8의 date 객체들을 쓰게 되면 더더욱 그렇다.


응답 처리


java 8의 LocalDateTime이나 LocalDate를 사용하면 응답하는 MappingJackson2JsonView의 기본 json 결과는 다음과 같다.
{ "localDateSample" : [2016,7,8], "localDateTimeSample" : [2016,7,1,0,0] }


serialized as numeric timestamps 형태로 년월일 / 년월일시분 값을 넘겨주는데 이런 형태로 응답하면 client의 javascript 객체에서 변환해서 쓸 때 불편한 점이 많다.

date format string 을 넘겨받아 사용하는게 더 사용하기 편하다고 생각을 해서 다음과 같이 boot properties 설정을 한다.
# response datetime 처리 설정
spring.jackson.serialization.write-dates-as-timestamps=false


위와 같이 설정을 하면 응답처리는 다음과 같이 된다.
{ "localDateSample" : "2016-07-08", "localDateTimeSample" : "2016-07-31T00:00" }

이렇게 사용하면 javascript에서 사용하기가 편해지고 timezone 설정이 들어간 경우도 moment.js 와 같은 라이브러리를 사용하면 쉽게 사용할 수 있다.


요청 처리


위와 같이 설정하는 경우 응답에 대해서 처리는 되었지만 요청이 온 경우에 대해서도 처리를 해주어야 한다.
요청에 대해 date format string 을 넘겨받는 설정은 WebMvcConfigurerAdapter에 다음과 같이 선언을 해준다.
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    ...

    // request date conversion 처리
    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setUseIsoFormat(true);
        registrar.registerFormatters(registry);
    }
}


여기까지 설정을 하면 @ResponseBody로 리턴되는 produces = MediaType.APPLICATION_JSON_VALUE 응답처리에 올바르게 적용된다.
하지만 html, json에 대한 contentNegotiation 처리에도 올바른 응답을 처리하기 위해선 WebMvcConfigurerAdapter에 다음과 같이 선언해야한다.
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    ...

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.viewResolver(thymeleafViewResolver);  // thymeleaf를 쓰는 경우
        registry.enableContentNegotiation(new MappingJackson2JsonView(objectMapper));
    }
}


위에서 중요한 핵심은 objectMapper를 spring boot에서 생성한 것을 써야한다는 점이다. 만약 new MappingJackson2JsonView() 로 생성한다면 spring boot 설정으로 선언한 objectMapper가 아닌 spring web의 Jackson2ObjectMapperBuilder로 생성한 objectMapper를 사용하기 때문에 jackson 설정이 동작하지 않는다.

2016년 6월 28일 화요일

spring mvc의 exceptionHandler에서 mediaType 정보 획득하기

Spring MVC 를 사용하다보면 contentNegotiatingViewResolver 는 거의 필수 사용 설정이다.

따라서 exceptionHandler를 사용할 때에도 mediaType에 따라 응답 결과를 처리해야한다.

이 때 가장 많이 고민하게 되는 부분이 Spring Security를 사용하는 경우 응답처리였다.

json으로 요청한 경우는 로그인이 필요하다는 exceptionMessage를 내려보내주고 html로 요청한 경우 security에서 처리한 로그인 페이지로 가는 처리를 할 필요가 있다.

이를 위해서는

  1. 응답요청에 대한 MediaType
  2. method 에 설정한 @RequestMapping  타입의 produces의 MediaType
  3. controller 에 설정한 @RequestMapping 의 produces의 MediaType

이 두 가지에 대해 exceptionHandler에서 확인이 가능해야한다.

다음과 같이 확인이 가능하다.


2016년 6월 27일 월요일

request 별로 variable 관리하기

웹페이지를 구성하다 보면 페이지 별로 메뉴를 구성하고 싶은 경우가 많다.

thymeleaf 를 쓰는 경우 최상위 dom인 html에 th:with로 응답별로 변수를 선언해서 쓸 수 있지만 페이지가 많아질 수록 이렇게 관리하는 것에 한계가 있다.

이런 경우 페이지 별 변수를 따로 관리해주는 것이 좋다.


xml과 java object를 맵핑하지 않았다.

이렇게 하면 java의 기본 list, map으로 해당 object가 만들어지는데 오히려 이렇게 사용하는 게 관리하기 편하다.

아래와 같은 기능이 있다.

1. 모든 메뉴의 이름은 원하는 대로 지어도 된다. (예제엔 menuList안에 menu로 구성했지만 모두 제각각으로 지어도 된다.)
2. 하위 뎁스를 원하는 만큼 만들어서 사용이 가능하다.
3. 다만 url이란 선언 값은 꼭 있어야 한다.
4. 중첩되는 선언들은 지정한 url의 패스가 긴 것부터 변수들이 우선순위를 가지고 merge된다.
5. xml 수정시 새로고침 하지 않아도 된다.

간단하면서도 유용한 requset별 variable 구현이 아닐까 싶다.

2016년 6월 22일 수요일

Docker Beta 버전

Docker가 드디어 베타버전으로 올라섰다.

그동안 알파 버전으로 오랜 기간 머물렀었는데 이 기간동안 컨테이너 포트를 유지하지 않으면 매번 초기화되거나, 버전업할 때마다 컨테이너 정보가 날아가고 호환이 안되는 등의 문제가 있었지만 나름 알차게 쓴 것 같다.

알파 버전을 떼고 나온 베타버전에는 큰 변화가 있다.

가장 큰 변화는 docker toolbox를 버렸다는 점이다.

docker toolbox는 각 os에 맞게 vm을 띄우기 위해 제공되었는데 virtual box를 설치하고 해당 virtual box에 core os를 올리는 등의 작업을 해주었다.

즉, 그동안 virtual box에 종속된 편의성의 toolbox를 제공하였는데 베타 버전 이후는 Mac, Windows는 각각의 OS에서 구현된 vm 방식을 사용하여 virtual box에 대한 종속성에서 벗어나게 되었다.

mac은 xhyve, windows는 Microsoft Hyper-V를 이용해 docker의 사용이 가능해졌다.

window의 경우 사용시 한가지 놓치면 안되는 설정이 있다.


위의 설정에서 experimental의 port 노출 설정이다.

아직 베타 버전이라 그런지 아니면 좀더 자세히 알지 못해서 그런지 몰라도 window에서 hyper-v를 통해 띄운 컨테이너를 로컬에서 접근하려면 저 설정을 해주어야 하더라..


그리고 kitematic의 응답이 느리고 변경점에 대한 반응이 바로바로 적용되지 않는 문제가 현재 보인다. (컨테이너를 중지해도 상태가 표시되지 않거나 콘솔이 이전 콘솔이 계속 보이는 현상 등..)

베타버전도 어서 빨리 개선이 되어 좀더 쾌적한 환경에서 사용을 할 수 있었으면 좋겠다.

2016년 6월 20일 월요일

Spring Boot war에서 MyBatis 사용시 TypeHandler scan 오류가 발생하는 현상

1번과 같이 typeHandler scan 범위를 지정하면 local eclipse에서는 동작하지만 spring boot war를 빌드한 후  호출하면 올바르게 동작하지 않는 현상이 있다.



이 경우 2번과 같이 일일이 typeHandler를 지정하여 해결할 수도 있다.

mybatis에서는 spring boot 사용시 war에 묶인 path가 /WEB-INF/lib에 묶여 올바른 패스가 인식 되지않는 경우를 위해 SpringBoot용 VFS를 제공한다.

3-1과 같이 mybatis-spring-boot-starter를 참조한 후 3-2와 같이 SpringBootVFS를 설정하면 spring boot에서 mybatis scan 설정이 올바르게 동작한다.

(참고 링크)




해당 문제는 mybatis가 버전업하면서 해결된 듯 하다.

MyBatis Java 8 관련 TypeHandler 설정 불필요