잊지 않겠습니다.

Spring Security를 이용한 REST API를 만들때, 일반적인 Spring Security를 이용하는 경우에는 다Login 성공 또는 실패시에 모두 302번 Redirect가 발생하게 됩니다. 또한 인증되지 않은 사용자가 API에 접근한 경우에도 마찬가지로 302번 Redirect로 Login Page로 이동하게 되는데, 이는 REST API 개발시에는 원하지 않는 결과입니다.

먼저, 개발할 API 서버는 Form 인증을 지원하는 것을 전제로 합니다. Basic/Digest 인증의 경우에는 다른 방법으로 개발이 되어야지 됩니다.

일반적인 Form 인증을 이용하는 경우에 Spring Security의 기본 Form인증은 다음 문제를 해결해야지 됩니다.

  1. 인증되지 않은 Request가 들어오는 경우, 인증 Page로 Redirect
  2. Login 성공시, 200 Response가 되지 않고, Success Page로 Redirect
  3. Login 실패시, 200 Response가 되지 않고, Failure Page로 Redirect

결국은 모두 Redirect문제가 발생하게 됩니다. 이에 조금 추가를 해서, 일반적으로는 Spring Security는 application이 시작할 때, 각각의 URL에 대한 ROLE을 설정하게 됩니다. 이러한 ROLE의 설정 없이, Request가 발생하였을때 인증된 사용자인 경우에 ROLE을 확인하는 기능을 추가해보도록 하겠습니다.

인증되지 않는 Request에 대한 문제해결

Spring Security는 ErrorHandling에 인증되지 않았을 때의 동작을 AuthenticationEntryPoint를 설정하여 제어할 수 있습니다. 기본적으로 Form인증을 사용하는 경우에는 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint가 설정이 됩니다. Spring code를 보면 다음과 같이 구성되어 있습니다.

public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
    public LoginUrlAuthenticationEntryPoint(String loginFormUrl) {
        this.loginFormUrl = loginFormUrl;
    }


    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {

        String redirectUrl = null;

        if (useForward) {
            if (forceHttps && "http".equals(request.getScheme())) {
                // First redirect the current request to HTTPS.
                // When that request is received, the forward to the login page will be used.
                redirectUrl = buildHttpsRedirectUrlForRequest(request);
            }

            if (redirectUrl == null) {
                String loginForm = determineUrlToUseForThisRequest(request, response, authException);

                if (logger.isDebugEnabled()) {
                    logger.debug("Server side forward to: " + loginForm);
                }

                RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);

                dispatcher.forward(request, response);

                return;
            }
        } else {
            // redirect to login page. Use https if forceHttps true

            redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);

        }

        redirectStrategy.sendRedirect(request, response, redirectUrl);
    }
}

보시면 최종적으로는 sendRedirect method를 통해서 login url로 redirect를 시켜주고 있는 것을 볼 수 있습니다. 이 부분에 대한 제어만을 변경시켜준다면 우리가 원하는 조건을 만들어줄 수 있습니다.

새롭게 만들어지는 RestAuthenticationHandler는 매우 단순한 코드를 갖습니다. 인증이 되지 않은 사용자들에게 401 status를 던질뿐이니까요.

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

이제 이 부분을 Spring Security Configuration에 설정을 해줍니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .and()
            .logout()
                .logoutUrl("/auth/logout");
    }
}

exceptionHandling()에 구성된 RestAuthenticationEntryPoint 객체만을 설정해주는 것으로 이제 인증되지 않은 request에는 401 status를 보내줄 수 있게 되었습니다.

인증 성공시, 200 status 반환

Spring Security를 이용한 Form 인증을 성공하는 경우, 일반적으로 SuccessfulUrl로 Redirect가 됩니다. 그런데 REST API의 경우에는 이와 같은 구성이 문제가 됩니다. 우리는 인증을 정상적으로 했는지에 대한 확인이 필요한 것이지, 인증을 한 후에 특정 Page로의 Redirect를 원하는 것이 아니기 때문입니다.

Form 인증에서, 인증이 성공한 경우에는 Spring Security 는 기본적으로 org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler에서 동작을 담당합니다. code를 보면 다음과 같습니다.

public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }
        String targetUrlParameter = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() || (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
            requestCache.removeRequest(request, response);
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }

        clearAuthenticationAttributes(request);

        // Use the DefaultSavedRequest URL
        String targetUrl = savedRequest.getRedirectUrl();
        logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    public void setRequestCache(RequestCache requestCache) {
        this.requestCache = requestCache;
    }
}

매우 단순한 code입니다. 여기서 중요한 것은 onAuthenticationSuccess method의 끝부분에 위치한 getRedirectStraegy()입니다. 이 부분만을 제거하고, 200 status만을 반환할 수 있다면 우리가 원하는 결과를 얻어낼 수 있습니다. 인증이 성공한 다음에 실행되기 때문에 매우 단순하게 만들 수 있습니다.

public class RestLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        handle(request, response, authentication);
        clearAuthenticationAttributes(request);
    }

    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            clearAuthenticationAttributes(request);
            return;
        }
        String targetUrlParam = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() ||
                (targetUrlParam != null &&
                        StringUtils.hasText(request.getParameter(targetUrlParam)))) {
            requestCache.removeRequest(request, response);
            clearAuthenticationAttributes(request);
            return;
        }
        clearAuthenticationAttributes(request);
    }
}

기본적으로 매우 단순하게 clearAuthenticationAttribute만 해주고, 다른 처리는 해줄 필요가 없습니다. 아무런 Exception이 발생하지 않고, response에 특별히 status를 설정하지 않는 경우에는 200 status를 반환하게 되니까요.

이제 작성된 AuthenticationSuccessHandler를 설정하면 Spring Security는 다음과 같은 코드가 됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) //추가된 부분
                .and()
            .logout()
                .logoutUrl("/auth/logout")
            .authorizeRequests().anyRequest().authenticated();
    }
}

인증 실패 및 로그 아웃시 동작 설정

인증 실패시의 동작과 로그 아웃시의 동작 역시 인증 성공시와 동일하게 처리할 수 있습니다. 조금 차이가 있다면 인증 실패시에는 401번 UNAUTHENTICATION status를 보내야지 되고, 로그 아웃시에는 항시 200 status를 보내야지 되는 차이만이 있을 뿐입니다.

인증실패시의 Handler는 다음과 같이 구성될 수 있습니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * when login failed, return http status code (401).
 */
public class RestLoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

그리고, 로그아웃시의 Handler 역시 다음과 같은 code로 구성가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * after Logouted, send http status ok code (200).
 */
public class RestLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response);
        if (response.isCommitted()) {
            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
        }
    }
}

이제 구성된 두개의 Handler를 추가하면 Spring Security는 다음 code와 같이 변경됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) 
                .failureHandler(loginFailureHandler()) //추가2
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler()) //추가2
            .authorizeRequests().anyRequest().authenticated();
    }
}

이렇게 구성하면 이제 Spring Security로 구성되는 REST API를 구성할 수 있습니다.

API URL별 권한 설정

REST API를 개발하면, 각 URL에 따른 사용 권한을 넣어주는 것이 일반적입니다. 또는 URL 단위로 권한을 묶어서 관리하게 되는데, 그럼 API가 하나하나 추가 될때마다 security의 설정에 매번 URL을 hard coding으로 넣어주게 된다면 이는 매우 큰 비용으로 발생되게 됩니다. 사용자에게 권한만 넘겨주고, 그 권한과 URL을 Request마다 체크를 해서 권한을 확인하면 Security에 대한 권한 코드를 좀더 동적으로 사용할 수 있습니다. (DB 값만을 변경시켜주면 URL에 따른 권한을 변경할 수 있습니다.)

먼저, Spring Security는 각각의 URL과 ROLE간의 Matching을 어떻게 하고 있는지에 대한 구조에 대한 이해가 필요합니다. Spring Security의 URL-ROLE간의 matching은 다음과 같이 진행됩니다.

  1. FilterInvocationSecurityMetadataSource을 통해 URL/METHOD를 이용한 접근권한이 어떻게 되는지 확인합니다. 이 과정이 매우 중요합니다. 우리가 Spring Security에 각각의 URL별 권한을 hard coding으로 넣어주는 경우에는, 이 MetadataSource가 hard coding된 것과 같은 역활을 하게 됩니다.
  2. FilterInvocationSecurityMetadataSource에서 얻어온 ROLE들과 사용자가 갖은 ROLE을 이용해서 AccessVote를 진행합니다.

이러한 두과정을 거치는 Filter가 존재를 하는데, 이 필터가 아래 그림의 최종적인 Filter인 FilterSecurityInterceptor입니다.

이러한 Filter Chain에서 새로운 Filter를 만들어서 Spring Security에 추가를 할 예정입니다. Spring Security의 FilterSecurityInterceptor에는 모든 인증된 Request만이 접근할 수 있도록 설정을 하고, 그 뒤에 우리가 새롭게 만든 Filter를 통해서 인증을 처리할 계획입니다.

먼저, 새로운 FilterInvocationSecurityMetaSource가 필요합니다. 이는 FilterInvocationSecurityMetadataSource interface를 상속하면 매우 간단하게 구성 가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * DB 기반의 인증 관리 시스템.
 */
public class FmsFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    public FmsFilterInvocationSecurityMetadataSource(MenuRoleService menuRoleService) {
        this.menuRoleService = menuRoleService;
        parser = new FmsUrlParser();
        permissions = new Hashtable<>();
    }
    private final MenuRoleService menuRoleService;
    private final FmsUrlParser parser;
    private final Map<String, Permission> permissions;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        final HttpServletRequest request = ((FilterInvocation) object).getRequest();
        final String httpMethod = request.getMethod().toUpperCase();
        final String url = parser.parse(request.getRequestURI());
        final String key = String.format("%s %s", httpMethod, url);

        final Permission permission;
        if(permissions.containsKey(key)) {
            permission = permissions.get(key);
        } else {
            permission = menuRoleService.findByMethodAndUrl(httpMethod, url);
            if(permission != null) {
                permissions.put(key, permission);
            }
        }

        String[] roles;
        if(permission == null) {
            roles = new String[] { "ROLE_ADMIN" };
        } else {
            roles = new String[] { "ROLE_ADMIN", permission.getName() };
        }
        return SecurityConfig.createList(roles);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

    /**
     * url - permission hashmap clear
     */
    public void clear() {
        permissions.clear();
    }
}

작성된 code의 menuRoleService는 request URL과 http method를 이용해서 접근 가능한 ROLE을 반환합니다. 약간의 tip으로, DB를 계속해서 읽는 것보다는 Hashtable을 이용해서 한번 읽어들인 ROLE은 또다시 DB 조회를 하지 않도록 MAP을 가지고 있도록 구성하였습니다.

이제 AccessVote를 구현할 차례입니다. AccessVote는 FilterInvocationSecurityMetadataSource에서 넘겨준 ROLE과 사용자의 ROLE간의 비교를 해주게 됩니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * Authorized Name 기반의 인증 Vote class
 */
@Slf4j
public class FmsAccessDecisionVoter implements AccessDecisionVoter<FilterInvocation> {

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute instanceof SecurityConfig;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz != null && clazz.isAssignableFrom(FilterInvocation.class);
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert fi != null;
        assert attributes != null;

        SecurityConfig securityConfig = null;

        boolean containAuthority = false;
        for(final ConfigAttribute configAttribute : attributes) {
            if(configAttribute instanceof SecurityConfig) {
                securityConfig = (SecurityConfig) configAttribute;
                for(GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
                    containAuthority = securityConfig.getAttribute().equals(grantedAuthority.getAuthority());
                    if(containAuthority) {
                        break;
                    }
                }
                if(containAuthority) {
                    break;
                }
            }
        }
        return containAuthority ? ACCESS_GRANTED : ACCESS_DENIED;
    }
}

AccessDecisionVoter는 하는 일이 매우 단순합니다. Authentication이 넘어왔고, 이에 대한 규칙을 이용해서 ROLE을 matching이 되는 것이 있는지 없는지를 확인하는 과정을 거치게 됩니다. 이 코드를 조금 더 수정을 한다면 권한 1, 권한 2, 권한 3 모두를 갖는 사용자만이 접근할 수 있는 규칙 역시 만드는 것이 가능합니다. 이건 Security에 대한 운영원칙과 BL에 따라 달라질 것입니다.

이제 구성된 FilterInvocationSecurityMetadataSource와 AccessDecisionVoter를 Spring Security에 추가해보도록 하겠습니다.

먼저 Bean들을 등록시켜줍니다.

    /**
     * FMS API 권한 Filter.
     * @return securityMetadataSource() 가 적용된 데이터.
     * @throws Exception
     */
    @Bean
    public FilterSecurityInterceptor filterSecurityInterceptor() throws Exception {
        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManager());
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        return filterSecurityInterceptor;
    }

    //여러 Voter를 추가하는 것이 가능. List로 index가 작은 Voter가 먼저 투표. 투표 포기(ACCESS_ABSTAIN)
    //가 되는 경우, 다음 Voter에게 넘어감.
    @Bean
    public AffirmativeBased affirmativeBased() {
        FmsAccessDecisionVoter voter = new FmsAccessDecisionVoter();
        List<AccessDecisionVoter> voters = new ArrayList<>();
        voters.add(voter);
        return new AffirmativeBased(voters);
    }

    @Bean
    public FilterInvocationSecurityMetadataSource securityMetadataSource() {
        return new FmsFilterInvocationSecurityMetadataSource(menuRoleService);
    }

그리고, FilterSecurityInterceptor 뒤에 우리가 만들어준 Filter를 등록시켜주면 됩니다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .and()
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler())
                .failureHandler(loginFailureHandler())
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
                .and()
            .authorizeRequests().anyRequest().authenticated()
                .and()
            .addFilterAfter(filterSecurityInterceptor(), FilterSecurityInterceptor.class);
    }

Summary

REST API 서비스 구현시에, Spring Security를 이용한 인증처리를 만들었습니다. 기본적인 Form 인증과 구성된 Security 다음 동작이 다릅니다.

동작기본 Spring Security FormREST Security Form
인증성공시설정된 인증 성공 URL로 302 Redirect200 status return
인증실패시설정된 인증 실패 URL로 302 Redirect401 unauthentication status return
로그아웃시설정된 로그아웃 성공 URL로 302 Redirect200 status return

또한, API의 추가 또는 권한변경시 코드의 변경 없이, DB값을 조회해와서 동적인 ROLE을 설정하는 것이 가능합니다.

인증은 매우 중요하고, 인증은 회사의 운영원칙에 많은 변경이 오게 되는 것이 사실입니다. 특히 REST API를 구성한다면 이 인증에 대한 많은 고민들이 필요하실 것이라고 생각하며 이만 글을 줄입니다. 모두들 Happy Coding~

Posted by Y2K
,

최신 Gradle 2.0으로 업데이트 한 내용이 존재합니다.


전에 정리한 적이 있는데, 조금 글을 다듬고 정리할 필요성이 있어서 다시 옮깁니다.
먼저, 개발되는 Project는 다음과 같은 구조를 갖습니다.

rootProject
-- domainSubProject (Spring Data JPA + queryDSL)
-- webAppSubProject (Spring Web MVC)

위와 같은 Project는 개발의 편의를 위해서 다음 두 조건을 만족해야지 됩니다.

  1. queryDSL을 사용하기 위한 Q-Entity를 생성이 compileJava task전에 수행되어야지 됩니다.
  2. web application 개발을 위해, tomcat을 실행시킬 수 있어야합니다.
  3. 개발된 web application을 test 환경 또는 product 환경에 배포가 가능해야지 됩니다.

root project

root project는 sub project들의 공통 설정이 필요합니다.

  1. build시에 필요한 plugin의 repository를 설정합니다.
  2. sub project들에서 사용될 공통 dependency들을 설정합니다. (sub project들의 build.gradle 이 너무 길어지는 것을 막을 수 있습니다.)
공통 plugin 추가
  • maven의 POM과 동일한 provided과 optional의 사용을 위해 spring prop plugin을 추가합니다.
  • subprojects들에 필요한 plugin들을 모두 추가합니다. (저는 java와 groovy, idea, eclipse 등을 추가했습니다.)
  • source java version과 target java version을 정해줍니다.
  • source code의 Encoding을 정해줍니다.
  • SubProject에서 기본적으로 사용될 dependency들을 모두 적어줍니다.

build.gradle (root project)

apply plugin: 'base'

// spring prop plugin 추가
buildscript {
    repositories {
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.5'
    }
}

subprojects {
    // Plugin 설정, 만약에 code에 대한 static analysis가 필요한 경우에 이곳에 설정.
    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'
    apply plugin: 'groovy'
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'

    // 기본적으로 사용할 repository들을 정의
    repositories {
        mavenCentral()
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
        maven { url "http://192.168.13.209:8080/nexus/content/repositories/releases" }
    }

    dependencies {
        def springVersion = "4.0.1.RELEASE"

        compile 'org.slf4j:slf4j-api:1.7.5'
        compile "org.springframework:spring-context:${springVersion}"
        compile "org.springframework:spring-aspects:${springVersion}"
        compile "org.springframework:spring-jdbc:${springVersion}"
        compile "org.springframework:spring-context-support:${springVersion}"

        compile 'mysql:mysql-connector-java:5.1.27'
        compile 'com.jolbox:bonecp:0.8.0.RELEASE'
        compile 'com.google.guava:guava:15.0'
        compile 'org.aspectj:aspectjrt:1.7.4'
        compile 'org.aspectj:aspectjtools:1.7.4'
        compile 'org.aspectj:aspectjweaver:1.7.4'

        testCompile 'org.springframework:spring-test:4.0.0.RELEASE'
        testCompile "junit:junit:4.11"

        groovy "org.codehaus.groovy:groovy-all:2.1.6"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
        testCompile "org.spockframework:spock-spring:1.0-groovy-2.0-SNAPSHOT"
    }

    // source, target compiler version 결정
    sourceCompatibility = 1.8
    targetCompatibility = 1.8

    // source code encoding
    [compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
}
domain project
  • domain project는 queryDsl의 Q-Entity들의 생성이 필요합니다.
  • compileJava에 대한 Q-Entity 생성 Task의 Depend가 잡혀있으면 사용하기 편합니다.

build.gradle (domain project)

dependencies {
    def springVersion = "4.0.1.RELEASE"

    compile 'org.hibernate:hibernate-core:4.3.1.Final'
    compile 'org.hibernate:hibernate-entitymanager:4.3.1.Final'
    compile "org.springframework:spring-orm:${springVersion}"

    def queryDSL = '3.2.4'
    compile("com.mysema.querydsl:querydsl-core:$queryDSL")
    compile("com.mysema.querydsl:querydsl-jpa:$queryDSL")
    compile("com.mysema.querydsl:querydsl-sql:$queryDSL")
    provided("com.mysema.querydsl:querydsl-apt:$queryDSL") {
        exclude group: 'com.google.guava'
    }
    compile 'org.springframework.data:spring-data-jpa:1.5.0.RELEASE'
}

sourceSets {
    generated {
        java {
            srcDirs = ['src/main/generated']
        }
    }
}

// QEntity 생성 task
task generateQueryDSL(type: JavaCompile, group: 'build', description: 'Generates the QueryDSL query types') {
    source = sourceSets.main.java
    classpath = configurations.compile + configurations.provided
    options.compilerArgs = [
            "-proc:only",
            "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
    ]
    destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
}

// compileJava task에 dependency를 걸어줍니다.
compileJava {
    dependsOn generateQueryDSL
    // compile target에 generated된 QClass들의 위치를 추가.
    source sourceSets.generated.java.srcDirs.iterator().next()
}

compileGeneratedJava {
    dependsOn generateQueryDSL
    options.warnings = false
    classpath += sourceSets.main.runtimeClasspath
}

clean {
    delete sourceSets.generated.java.srcDirs
}

idea {
    module {
        sourceDirs += file('src/main/generated')
    }
}
webapplication project

마지막으로 web application project입니다. 이는 조건이 조금 더 많습니다.

  • tomcat을 실행시켜 local 개발 환경에서 사용할 수 있어야지 됩니다.
  • 외부 테스트 또는 운영환경의 tomcat에 배포가 가능해야지 됩니다.

위 조건을 만족하기 위해서 gradle의 tomcat plugin과 cargo plugin을 이용합니다.

plugin에 대한 자료들은 다음 url에서 보다 많은 자료를 볼 수 있습니다.

tomcat plugin > https://github.com/bmuschko/gradle-tomcat-plugin
cargo plugin > https://github.com/bmuschko/gradle-cargo-plugin

build.gradle (webapp project)

apply plugin: 'war'
apply plugin: 'tomcat'
apply plugin: 'cargo'

// tomcat과 cargo plugin에 대한 repository 설정입니다.
buildscript {
    repositories {
        mavenCentral()
        jcenter()
    }

    dependencies {
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath 'org.gradle.api.plugins:gradle-cargo-plugin:1.4'
    }
}


dependencies {
    // tomcat plugin 설정입니다.
    String tomcatVersion = '7.0.47'
    tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
    tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
    tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
        exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
    }
    providedCompile 'javax.servlet:javax.servlet-api:3.1.0'
    providedCompile 'javax.websocket:javax.websocket-api:1.0'
    providedCompile 'javax.servlet:jsp-api:2.0'
    providedCompile "org.apache.tomcat:tomcat-servlet-api:${tomcatVersion}"

    // cargo에 대한 설정입니다.
    def cargoVersion = '1.4.5'
    cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
            "org.codehaus.cargo:cargo-ant:$cargoVersion"


    def springVersion = "4.0.1.RELEASE"
    compile "org.springframework:spring-webmvc:${springVersion}"
    compile 'jstl:jstl:1.2'
    compile 'org.apache.tiles:tiles-jsp:3.0.3'

    compile 'org.slf4j:slf4j-api:1.7.6'
    compile 'org.slf4j:jcl-over-slf4j:1.7.6'

    compile 'ch.qos.logback:logback-classic:1.0.13'
    compile 'ch.qos.logback:logback-core:1.0.13'

    compile 'org.apache.velocity:velocity:1.7'
    compile 'org.freemarker:freemarker:2.3.20'
    compile 'com.ctlok:spring-webmvc-rythm:1.4.4'
    compile project(':bookstoreHibernate')
}

// tomcarRun을 실행시키기 위해서 war에 대한 dependency를 주입합니다.
tomcatRun {
    contextPath = ""
    URIEncoding = 'UTF-8'
    dependsOn war
}

tomcatRunWar {
    dependsOn war
}

// cargo를 이용한 배포를 위해서 war에 대한 dependency를 주입합니다.
cargoRedeployRemote {
    dependsOn war
}

cargoDeployRemote {
    dependsOn war
}

cargo {
    containerId = 'tomcat7x'
    port = 8080

    deployable {
        context = "${project.name}"
    }

    // remoteDeploy 되는 target의 tomcat 정보
    remote {
        hostname = '192.168.13.209'
        username = 'ykyoon'
        password = 'qwer12#$'
    }
}

bower를 이용한 javascript dependency

web application에서의 외부 javascript dependency를 사용하는 방법입니다. bower를 이용하는 경우, 외부에서 javascript에 대한 source code를 모두 다운받고 compile된 javascript를 dist에 저장하게 됩니다.

그런데, 우리의 web application은 dist에 저장된 특정 파일만을 사용하게 됩니다. 그럼 이 dist에 있는 file을 최종적으로 배포할 webapp folder에 넣어줘야지 됩니다. 이를 위해서 개발된 것이 bower-installer 입니다. 그런데 bower-installer의 경우에는 윈도우즈에서 동작이 정상적이지 않습니다. 아니 실행이 되지 않습니다.; 그래서 bower-installer와 동일한 동작을 하는 task를 만들어봤습니다.

먼저, bower-installer는 bower.json의 install property에 설정합니다. jquery와 bootstrap에 대한 dependency를 설정한 bower.json 입니다.

bower.json

{
    "name" : "bookstore-web",
    "version" : "0.0.0.1",
    "dependencies" : {
        "jquery" : "1.11.0",
        "bootstrap" : "3.1.1"
    },
    "install" : {
        "path" : {
            "css" : "src/main/webapp/lib/css",
            "js" : "src/main/webapp/lib/js",
            "eot" : "src/main/webapp/lib/fonts",
            "svg" : "src/main/webapp/lib/fonts",
            "ttf" : "src/main/webapp/lib/fonts",
            "woff" : "src/main/webapp/lib/fonts",
            "map" : "src/main/webapp/lib/js"
        },
        "sources" : {
            "jquery" : [
                    "bower_components/jquery/dist/jquery.min.js",
                    "bower_components/jquery/dist/jquery.min.map"
                ],
            "bootstrap" : [
                    "bower_components/bootstrap/dist/css/bootstrap.min.css",
                    "bower_components/bootstrap/dist/css/bootstrap-theme.min.css",
                    "bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot",
                    "bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg",
                    "bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf",
                    "bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff",
                    "bower_components/bootstrap/dist/js/bootstrap.min.js"
                ]
        }
    }
}

위의 install property에 지정된 js와 css들을 옮기는 task는 다음과 같이 설정할 수 있습니다. war task에 dependency를 주입해서 위의 tomcatRun이나 cargoRedeployRemote 등에서도 사용할 수 있습니다.

import org.apache.tools.ant.taskdefs.condition.Os
import groovy.json.JsonSlurper
task bowerInstall(type:Exec, description : 'copy js files dependencies that is defined in Bower.js') {

    if(Os.isFamily(Os.FAMILY_WINDOWS)) {
        commandLine 'cmd', 'bower', 'install'
    } else {
        commandLine 'bower', 'install'
    }

    def jsonHandler = new JsonSlurper()
    def jsonFile = file("bower.json")
    def conf = jsonHandler.parseText(jsonFile.getText("UTF-8"))
    def pathMap = [:]

    conf.install.path.each {
        pathMap.put(it.key, it.value)
    }

    conf.install.sources.each {
        it.value.each { f ->
            def sourceFile = file(f)
            String sourceName = sourceFile.name
            int dotPos = sourceName.lastIndexOf(".")
            String ext = sourceName.substring(dotPos + 1, sourceName.length())
            if(pathMap.containsKey(ext)) {
                copy {
                    from f
                    into pathMap.get(ext)
                }
            }
        }
    }
}

war {
    dependsOn bowerInstall
}


위 정리된 내용을 github에 공유합니다. 아래 주소에서 git clone 하시면 됩니다. ^^

 

 https://github.com/xyzlast/study-spring-bookstore.git



Posted by Y2K
,

Java VM - YoungGen space의 동작

번역(http://javaeesupportpatterns.blogspot.ie/2013/11/java-vm-beware-of-younggen-space.html) 입니다. 약간의 의역과 오역이 들어가있을수 있습니다. ^^;

정상적인 JVM 상태는 application의 성능 및 안정성에 매우 중요한 목표라고 할 수 있다. 이러한 health 평가는 GC의 major collection이나 memory leak에 촛점이 맞춰져 있다. Young Generation space 또는 shot lived object의 경우에는 크기나 저장 공간이 어떻게 될까?

이 article은 우리의 client의 최신 장애 경험에 의거한 이야기이다. 샘플 application을 통해, 이 현상을 보여줄 것이며, 메모리 저장소와 minor collection이 Old Generation collection 또는 tenured space와 비슷한 정도의 성능저하를 나타낼 수 있음을 보여줄 것이다.

JVM Health diagnostic

만약에 당신이 JVM tunning의 신참이라면, 모든 application에 공통으로 사용할 수 있는 일반적인 솔루션은 존재하지 않는다는 것은 곧 알 수 있을 것이다. Web을 통한 여러 소스의 경로를 통해, 여러분들은 JVM GC pauses가 매우 섬세하며 이것을 이해하기 위해서는 매우 많은 노력을 해야지 된다는 것을 알 수 있다. (어떤 application은 JVM pause time을 1% 이하로 줄이길 요구한다.)

성능과 load testing을 같이 하는 Java profiling (메모리 leak detection을 포함)은 JVM runtime health와 당신의 application의 memory 저장공간에 대한 데이터와 시실을 모두 수집하기 위한 좋은 방법이 된다.

그럼, “Healthy” JVM 이란 무엇을 의미하는가? 이 질문에 대해서 당신의 최고의 지식을 가지고 다음 질문들을 대답해보길 바란다.

만약에 당신이 NO라고 대답한다면, 90%이상의 확신을 가진 대답을 해주면 된다. 또는 I DON’T Know 라고 해도 된다.

  • Application에서 Java heap 또는 OldGen space leaking over time이 발생하는가? (major collection이 발생한 경우)
  • Application에서 현재 크거나 잦은 JVM GC Collection이 발생하고 있는지?
  • JVM overall pause time이 5%보다 넘거나 이상적인 baseline보다 높은지?
  • Application의 response time이 JVM GC 행위에 영향을 받고 있는지?
  • 3개월 내로, java.lang.OutOfMemoryError error를 경험해본적이 있는지?
  • 3개월 내로, JVM의 crush 현상을 격어본적이 있는지?
  • 당신의 JVM 상태가 현제 불안정하거나 인위적인 개입을 받아야지 되는 상태에 있는지? (JVM reboot etc.. )

만약에 당신이 YES 또는 I don’t know라고 대답을 했다면, 당신의 production 성능을 높이기 위해서 tuning team이 현 JVM GC policy를 살펴서 해야지 될 일들이 남아있다는 것이다.

만약에 당신이 모든 질문에 NO라고 대답할 수 있다면, 당신은 매우 견고한 application과 JVM 안정성을 가지고 있는 것이다. 축하한다. 여전히 나는 당신에게 major release와 load forecasts에 대한 상황을 평가하는 것을 제안한다.

Young Generation : Stop-The-World

우리가 JVM Health 평가를 진행할 때, JVM overall pause time은 자주 언급되는 문제이다. JVM overall pause time은 JVM이 stop the world event가 발생할 때, 얼마나 많은 시간이 걸리는지를 측정하는 것이다. Event가 발생중에, application thead는 모두 중지되고, 어떠한 일도 동작하지 않으며, application의 응답시간은 증가되게 된다. 이 수치는 JVM의 불안정성 이나 예측 불가능한 응답시간에 대한 매우 중요한 지표로 사용된다.

최근 몇년을 거쳐 사람들이 많이 알고 있으나, 잘못된 정보는 YoungGen 또는 minor collection은 매우 명료하며, application response time에 영향을 주지 않는다.라는 선입관이다. 이 문장은 대부분의 경우에 참이지만, Java heap size가 작고 (YG space < 1GB), 생명주기가 매우 짧은 경우만을 한정해야지 된다. 위 시나리오는 minor collection 실행이 매우 빠르며 (< 20msec), 자주 일어나지 않으며 (every 30 seconds++), overall JVM pause 시간중 YoungGen space가 매우 작게 남아있는 경우이다(<< 1%). 그러나, YG memory 할당 빈도수는 매우 빠르게 증가되어 이러한 상황은 매우 쉽게 변화될 수 있다(traffic의 증가로 인하여 사용자가 증가하는 경우, YG의 Size 및 할당 빈도수는 매우 쉽게 증가한다).

다음 article에서 제안한 내용을 중심으로 YoungGen space와 concurrent collectors에 대한 정보를 좀 더 얻기를 바란다.

Oracle HotSpot mostly concurrent collectors: CMS vs. G1

http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html

Oracle HotSpot minor collections exhaustive coverage

http://www.oracle.com/technetwork/java/javase/gc-tuning-6-140523.html
http://blog.griddynamics.com/2011/06/understanding-gc-pauses-in-jvm-hotspots_02.html

이미 사용하고 있는 HotSpot GC 규정에 상관없이, 모든 collector들 CMS, G1, YoungGen space collection 모두는 “stop the world”를 발생시킬 수 있다. 우리의 지식대로, Azul Zing C4만이 실시간 compacting collectior인 것으로 알려지고 있다. 우리는 현 시점에서 이 collector를 사용할 수 있는 경험을 갖지 못했다. C4 tuning 경험이나 연구 결과가 있다면 많은 공유를 부탁드린다.

지금 우리는 약간의 이론을 소개하도록 한다. sample application을 통해, 성능 검사 결과는 다양한 YoungGen의 저장공간과 할당 빈도를 보여줄 것이다.

Sample application specification

YG 할당빈도수에 따른 JVM pause time %를 측정하기 위해 우리는 다음 sample application을 작성하였다.

// jvm uri를 갖는 REST WebService (JAX-RS)
@GET
@Path("/jvm")
@Produces(MediaType.APPLICATION_JSON)
public Integer jvm() {}

jvm method의 실행은 다음 로직을 따르게 된다.

이미 결정된 크기의 short-lived object를 할당한다. (fast Young GC의 요건에 적합하다.)

추가적으로, 1 GB의 CMS collector의 동작의 약간의 noise를 가할 목적의 long-lived objects의 저장소를 만들어준다(GC의 대상이 아니다.).

private final static int LONG_LIVED_OBJ_FOOTPRINT = (1024 * 1024 * 1024);
private final static int SHORT_LIVED_OBJ_FOOTPRINT = (100 * 1024 * 1024);

// 1 GB static memory footprint
private final static byte byteArrayLongLivedObj[] = new byte[LONG_LIVED_OBJ_FOOTPRINT];

// 100 MB memory allocation (waste) created per execution
public void generateShortLivedObj(String objId) {          
  byte byteArrayShortLivedObj[] = new byte[SHORT_LIVED_OBJ_FOOTPRINT];
}

마지막으로, 테스팅하는 환경 변수는 다음과 같다.

OS: Windows 7 @64-bit
Java EE container: WildFly 8 Beta1
JVM: Oracle HotSpot 1.7 @64-bit with a Java heap size of 5 GB (YG space: 1152 MB XX:NewRatio=3). GC collector: CMS
IDE: JBoss Developer Studio 7.0.0.GA
JVM monitoring: JVisualVM
JVM memory leak analyzer: Plumbr 3.0
JVM verbose:gc log analyzer: GCMV
Performance & load testing: Apache JMeter 2.9

Performance testing results and observations

Simulated된 성능 테스팅은 높은 JVM pause time을 가지고 있었으며, peak load하에서 잦은 성능저하를 보여줬다.

Baseline

  • 10개의 concurrent thread
  • JVM process당 100 MB의 short lived object.

short lived object memory 저장 공간은 매우 극적이지만, 처음에는 잘 운영되었다.

Result

Average response time: 140 ms
Throughput: 68 req / sec
JVM overall pause time: 25.8%
YG collection frequency: 7 collections per second
Rate of GC: 308,909 MB per minute

JvirtualVM에 따르면, 이 JVM은 정상적이다(memory leak이 없으며, 안정적이며, OldGen collect 횟수가 낮다). 그러나, verbose:gc log를 좀 더 깊게 바라보면, JVM pause time이 전체 runtime 시간의 25.8%가 되는 것을 알 수 있고, 모든 지연시간은 YG Collection에 의해서 발생됨을 알 수 있다. 이것은 명확하게 verbose:gc log vs JVM에 대한 검증된 기준과의 차이에 대한 적절한 분석이 필요함을 보이고 있다.

Test & tuning #1.

  • 10 concurrent threads
  • 50 MB of short lived objects / JVM process

(먼저번의 테스트와의 차이점은 short lived object의 크기가 다르다는 점이다.)

위 조건에서 application의 메모리 할당 용량은 100MB에서 50MB 사이에서 동작하고 있었다. 우리는 명확히 개선된 것을 모든 지표에서 볼 수 있었으며 request 당 할당되는 메모리 양의 감소가 처리양에 관련이 있는 것을 볼 수 있다.

Result

Average response time: 119 ms  -21
Throughput: 79 req / sec  +11
JVM overall pause time: 15.59%  -10.21
YG collection frequency: 3-4 collections per second  -3
Rate of GC: 164 950 MB per minute  -143 959

Test & tuning #2

  • 10 concurrent threads
  • 5 MB of short lived objects created per execution per JVM process

(Test 1에 비하여 request 당 생성되는 short lived object의 크기가 5M로 반으로 줄었다.)

Result

Average response time: 107 ms  -33
Throughput: 90 req / sec  +22
JVM overall pause time: 1.9%  -23.9
YG collection frequency: 1 collection every 2-3 seconds * significant reduction
Rate of GC: 15 841 MB per minute  -293 068

보는것과 같이, application footprint와 memory 할당에 대한 최종 개선은 의미있는 매우 적절한 1.9%정도인 JVM pause time을 보여주고 있다.3번의 테스트를 통해서 매우 중요한 점을 알 수 있는데, OldGen footprint와 CMS 동작은 JVM pause time에 전혀 영향을 주지 않고 있으며, 성능 문제는 과도한 동작과 YG Collection에 연결된 매우 큰 크기의 stop the world event와 연결되어 있음을 알 수 있다.

Solution & recommandations

이 문제 케이스에서 우리는 JVM pause time을 줄이기 위해서는 연관된 과도한 YG collection 활동에 대한 tuning과 application request에 의한 memory footprint를 제거시켜주는것이 중요함을 알 수 있다. 따라서, application rate와 YG GC frequency를 줄이는 것이 중요하다.

그러나, 이러한 tuning 전략은 단기간에 가능하지 않는데, 다른 솔루션들을 분석하는 것이 더욱더 가치가 있기 때문이다. 이와 유사한 결과는 어쩌면 다음과 같은 허용량 개선 전략으로도 성취할 수도 있다.

  • Horizontal & Vertical scaling : traffic을 증가된 숫자의 JVM process로 나누면 메모리 할당 빈도와 YG Collection 횟수를 줄일 수 있다. 이는 근본적인 원인을 hardware로 넘기는 방법이다. 나의 추천사항은 언제나 당신의 application memory footprint를 먼저 tunning하고, 다른 scaling option을 찾는 것이다.
  • Java heap size & YG ratio tuning : YG 영역의 크기의 증가는 틀림없이 YG Collections에 의한 frequency를 줄일 수 있다. 그러나 매우 주의해야지 된다. 절대로 OldGen space를 굶주리게 만들어서는 안된다 그렇지 않으면 당신은 매우 간단히 문제를 JVM thrashing과 OOM events쪽으로 옮겨버리게 된다.

Final words

JVM YG collections의 성능 영향에 대해서 보다 나은 이해가 있기를 바란다. 나는 이 article을 읽은 후에 다음과 같은 실습을 해보기를 권합니다.

  • 당신이 맡고 있는 가장 바쁘게 돌아가는 application을 선택합니다.
  • verbose:gc log를 살펴보고, JVM pause time과 GCMV를 결정해보세요.
  • YG collection에 의한 영향을 판단하고, tunning 기회를 알아보시길 바랍니다.

당신의 comment와 JVM tuning 경험 기회를 공유할 수 있기를 바랍니다.


Posted by Y2K
,

(원문 : http://javaeesupportpatterns.blogspot.ie/2013/02/java-8-from-permgen-to-metaspace.html)

Java8에서 가장 큰 변화라고 개인적으로 생각하고 있는 Metaspace에 대한 post를 한번 번역해서 소개합니다.

JDK7까지 사용된 Permanent General (PermGen) space가 Oracle의 주도하에 제거된 것이 큰 특징중 하나입니다. 예를 들어, 내부 문자열의 경우, JDK7에서 이미 제거되어있었으나, JDK8에서 최종적으로 폐기되게 되었습니다.

이 문서는 PermGen의 후계자라고 할 수 있는 Metaspace에 대한 내용입니다. 또한 우리는 HotSpot 1.7 vs HotSpot 1.8 간의 leaking class meta object의 동작에 대해서 알아보도록 하겠습니다.

Metaspace : 새로운 메모리 저장소의 탄생

JDK8 HostSpot VM의 경우, 재사용성을 위한 객체에 대한 metadata를 Metaspace라고 불리우는 native memory에 저장하게 됩니다. 마치 Oracle JRockit 또는 IBM JVM과 같은 동작으로 구성되게 됩니다.

좋은 뉴스는 이제는 더이상 java.lang.OutOfMemoryError : PermGen space와 같은 문제를 더이상 야기하지 않는다는 것과 더이상 tunning과 monitoring을 통해서 이러한 memory space를 조절할 필요가 없다는 것입니다. 그렇지만, 이러한 변화는 기본적으로 안보이게 설정이 되기 때문에 우리는 이 새로운 meta memory에 대한 족적을 여전히 살펴볼 필요성은 존재하게 됩니다. 이러한 변화가 마법과 같이 class의 메모리 공간을 줄여주거나 class loader의 memory leak을 줄여주지는 못하는 것을 인지해야지 됩니다. 우리는 새로운 naming convention하에서 다른 시각으로 이러한 문제를 바라보는 것이 필요합니다.

Summary

PermGen space situation

  • memory space는 완벽하게 제거됩니다.
  • PermSize와 MaxPermSize JVM argument는 무시되며, 시작될때 경고를 표시합니다.

Metaspace memory allocation model

  • class의 metadata는 거의 대부분 native memory에 저장됩니다.
  • class의 metadata를 사용하기 위한 klasses들은 모두 제거됩니다.

Metaspace capacity

  • 기본적으로 metaspace는 native memory를 사용하기 때문에, OS에서 사용 가능한 memory 영역 모두를 사용 가능합니다.
  • 새로운 Flag(MaxMetaSpaceSize)가 가능합니다. 이는 class metadata를 저장하기 위한 최대한의 memory size를 정하는 것이 가능합니다. 만약에 Flag를 설정하지 않는다면, Metaspace는 application의 동작에 따라 가변적으로 동작하게 됩니다.

Metaspace garbage collection

  • dead class와 classloader의 GC는 MaxMetaspaceSize에 도달하면 발생합니다.
  • Metaspace의 tuning과 monitoring은 GC의 횟수와 지역을 제한하기 위해 필요한 작업입니다. 과도한 Metaspace GC는 classloader의 memory leak 또는 적합하지 않은 Application의 memory size를 초래합니다.

Java heap space impact

  • 여러 다양한 데이터들은 Java Heap space로 저장 영역이 이동되었습니다. 결국 기존 JDK7에서 JDK8로 업그레이드를 하는 경우, Java Heap memory를 증가시킬 필요가 있습니다.

Metaspace monitoring

  • metaspace 사용량은 GC log output을 통해서 확인 가능합니다.
  • Jstat * JVirtualVM 은 아직 metaspace를 monitoring 하지 못합니다. 아직 예전 PermGen space reference를 확인하도록 되어 있습니다.

PremGen vs. Metaspace runtime comparison

새로운 Metaspace memory space의 동작을 이해하기 위해서, 우리는 metadata leaking java program을 준비했습니다.

주어진 시나리오에 따라 다음과 같이 테스트를 진행했습니다.

  • JDK 1.7에서 PermGen memory space를 128M로 설정 후, PermGen memory의 감소를 관찰합니다
  • JDK 1.8에서 Metaspace memory space의 GC와 dynamic increase를 monitoring 합니다.
  • JDK 1.8에서 MaxMetaspaceSize를 128M로 설정 후, Memory의 감소를 관찰합니다.

JDK 17 - PermGen depletion

  • Java program with 50K configured iterations
  • Java heap space는 1024 MB
  • Java PermGen space 는 128MB

JVirtualVM의 경우, PermGen depletion은 약 30K+개의 class가 class loader에 로딩되었을 때, 발생합니다. GC output을 통해 다음 log를 확인할 수 있습니다.

Class metadata leak simulator
Author: Pierre-Hugues Charbonneau
http://javaeesupportpatterns.blogspot.com
ERROR: java.lang.OutOfMemoryError: PermGen space

JDK 1.8 - Metaspace dynamic re-size

  • Java program with 50K configured iterations
  • Java heap space는 1024 MB
  • Java metaspace space : 지정하지 않음 (default)


GC output에서 볼 수 있듯이, JVM Metaspace는 20MB에서 320MB로 dynamic resizing이 이루어진 것을 볼 수 있습니다. 또한 JVM의 GC 시도를 GC output을 통해서 확인 할 수 있습니다. 우리는 memory leak이 발생하는 application을 구성하고 있기 때문에, JVM은 dynamic expand 밖에 할 수 없습니다.

이 프로그램은 50K 정도의 OOM event와 50K+ 정도의 classes들을 load 시킬 수 있습니다.

JDK 1.8 - Metaspace depletion

  • Java program with 50K configured iterations
  • Java heap space는 1024 MB
  • Java metaspace space : 128M (-XX : MaxMetasapceSize=128M)

JVirtualVM에서 보듯이, metaspace 감소는 약 30K+ 개의 class들이 감소한 후 부터 발생하고 있습니다. 마치 JDK 1.7과 동작이 비슷합니다. 이러한 현상을 GC output을 통해서 확인할 수 있습니다. 또다른 흥미로운 현상은 native memory footprint를 보면 알 수 있듯이, native memory는 MaxMetaspaceSize의 2배를 할당하고 있다는 점입니다. 이것은 native memory 낭비를 줄일, Metaspace의 재할당 정책에 대한 좋은 tunning point입니다.

이제 java program output을 통해 Exception을 확인해보도록 하겠습니다.

Class metadata leak simulator
Author: Pierre-Hugues Charbonneau
http://javaeesupportpatterns.blogspot.com
ERROR: java.lang.OutOfMemoryError: Metadata space
Done!

예상과 같이, Metaspace가 128MB가 되었을 때, 마치 JDK 1.7과 거의 유사한 base line을 갖는 50 K 정도의 객체를 생성할 때 Exception이 발생합니다. 새로운 OOM(Out Of Memory) error는 JVM에 의해서 발생되게 됩니다. OOM event는 memory allocation failuire에 의해 발생되게 됩니다.

metaspace.cpp 

Final words

새로운 Java 8 Metaspace에 대해서, 빠른 분석과 설명을 알아보길 바랍니다. 현 분석대로라면 마지막 실험에서 보셨던 것과 같이, Metaspace GC 또는 OOM 조건이 발생하는 것에 대한 monitoring과 tuning이 필요한 것을 보여줍니다. 다음 article에서는 새로운 기능에 연관된 performance 향샹에 대한 비교를 포함할 수 있었으면 좋겠습니다.

Posted by Y2K
,

매번 코드를 작성할 때마다 까먹어서 정리합니다. 기억력이 감퇴가 된것 같네요.

  1. MessageSource를 선언. refresh를 위해 ReloadableResourceBundleMessageSource를 사용하는 것이 정신건강상 좋다.
  2. LocaleChangeInterceptor를 선언
  3. addInterceptors method를 override 시켜, interceptor를 추가
  4. AcceptHeaderLocaleResolver, CookieLocaleResolver, SessionLocaleResolver 중 하나를 선택하여 LocaleResolver를 구현

AcceptHandlerLocaleResolver

기본적인 Http Header인 Accept-Language를 이용하는 방법입니다. 기본값이 AcceptHeaderLocaleResolver로 되어 있습니다. 가장 구현이 간단하나, parameter를 통해서 동적으로 Locale을 변경하지 못합니다. UnsupportedOperationException이 발생합니다. 이를 테스트 하기 위해서는 Browser의 설정중에 언어 셋을 변경시켜야지 됩니다.

CokieLocaleResolver

Cookie를 이용하는 방식으로, Cookie name, expired time 등을 설정 가능합니다.

SessionLocaleResolver

Session값을 이용하는 방법으로 서버의 부하측면이 적다면 가장 좋은 방법인것 같습니다.

@Configuration code는 다음과 같습니다.

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = {
        "me.xyzlast.web.controllers"
})
public class ControllerConfiguration extends WebMvcConfigurerAdapter {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/lib/**").addResourceLocations("/lib/**");
        super.addResourceHandlers(registry);
    }

    @Bean
    public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() throws IOException {
        return new PropertySourcesPlaceholderConfigurer();
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
        super.addInterceptors(registry);
    }

    @Bean
    public AcceptHeaderLocaleResolver acceptHeaderLocaleResolver() {
        return new AcceptHeaderLocaleResolver();
    }

    @Bean
    public ReloadableResourceBundleMessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setDefaultEncoding("UTF-8");
        messageSource.setBasename("classpath:messages");
        messageSource.setFallbackToSystemLocale(false);
        return messageSource;
    }
}


Posted by Y2K
,

Spring에서 xml이나 annotation을 이용한 component-scan에 의해서 등록되는 Bean중에서 @PersistenceContext의 동작원리는 다음과 같습니다.

ComponentScanBeanDefinitionParser

ComponentScanBeanDefinitionParser.registerComponents를 통해 Bean을 등록시켜준다. annotationConfig가 enable된 상태에서 context의 Bean들을 scan시켜줍니다.

protected void registerComponents(
        XmlReaderContext readerContext, Set<BeanDefinitionHolder> beanDefinitions, Element element) {

    Object source = readerContext.extractSource(element);
    CompositeComponentDefinition compositeDef = new CompositeComponentDefinition(element.getTagName(), source);

    for (BeanDefinitionHolder beanDefHolder : beanDefinitions) {
        compositeDef.addNestedComponent(new BeanComponentDefinition(beanDefHolder));
    }

    // Register annotation config processors, if necessary.
    boolean annotationConfig = true;
    if (element.hasAttribute(ANNOTATION_CONFIG_ATTRIBUTE)) {
        annotationConfig = Boolean.valueOf(element.getAttribute(ANNOTATION_CONFIG_ATTRIBUTE));
    }
    if (annotationConfig) {
        Set<BeanDefinitionHolder> processorDefinitions =
                AnnotationConfigUtils.registerAnnotationConfigProcessors(readerContext.getRegistry(), source);
        for (BeanDefinitionHolder processorDefinition : processorDefinitions) {
            compositeDef.addNestedComponent(new BeanComponentDefinition(processorDefinition));
        }
    }

    readerContext.fireComponentRegistered(compositeDef);
}

AnnotationConfigUtil

AnnotationConfigUtil을 통해 Annotation이 설정된 Bean들을 얻어오기 시작합니다. registerAnnotationConfigProcessors에 의해서 각각의 Type에 따라 다른 등록을 Process 처리하게 됩니다.

if (!registry.containsBeanDefinition(CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(ConfigurationClassPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME));
}

if (!registry.containsBeanDefinition(AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(AutowiredAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME));
}

if (!registry.containsBeanDefinition(REQUIRED_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(RequiredAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, REQUIRED_ANNOTATION_PROCESSOR_BEAN_NAME));
}

// Check for JSR-250 support, and if present add the CommonAnnotationBeanPostProcessor.
if (jsr250Present && !registry.containsBeanDefinition(COMMON_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(CommonAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, COMMON_ANNOTATION_PROCESSOR_BEAN_NAME));
}

// Check for JPA support, and if present add the PersistenceAnnotationBeanPostProcessor.
if (jpaPresent && !registry.containsBeanDefinition(PERSISTENCE_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition();
    try {
        ClassLoader cl = AnnotationConfigUtils.class.getClassLoader();
        def.setBeanClass(cl.loadClass(PERSISTENCE_ANNOTATION_PROCESSOR_CLASS_NAME));
    }
    catch (ClassNotFoundException ex) {
        throw new IllegalStateException(
                "Cannot load optional framework class: " + PERSISTENCE_ANNOTATION_PROCESSOR_CLASS_NAME, ex);
    }
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, PERSISTENCE_ANNOTATION_PROCESSOR_BEAN_NAME));
}

처리되는 Bean들은 다음과 같습니다.

  • CONFIGURATIONANNOTATIONPROCESSORBEANNAME : @Configuration이 적용된 객체들을 처리하는 Process 입니다.
  • AUTOWIREDANNOTATIONPROCESSORBEANNAME : Bean 객체들의 @Autowired가 적용된 객체들을 처리하는 Process 입니다.
  • REQUIREDANNOTATIONPROCESSORBEANNAME : @Required Annotation이 처리됩니다.
  • COMMONANNOTATIONPROCESSORBEANNAME : EJB, JNDI 지원 객체들의 Property들을 처리하는 Process 입니다.
  • PERSISTENCEANNOTATIONPROCESSORBEANNAME : JPA를 처리합니다. 실질적으로 JPA의 EntityManagerFactory 객체의 경우에는 PersistenceAnnotationBeanPostProcess 에서 처리하게 됩니다.
PersistenceAnnotationBeanPostProcess

이제 마지막 코드입니다. PersistenceAnnotationBeanPostProcess는 @PersistenceContext로 지정된 Property에 EntityManagerFactory에서 새로운 EntityManager를 생성하거나, Transaction에 의해 기존ㅇ에 생성된 EntityManager를 반환시켜서 사용하게 해줍니다. 이에 대한 코드는 다음과 같습니다.

먼저, EntityManagerFactory를 생성하고 주입하고 있습니다.

public PersistenceElement(Member member, PropertyDescriptor pd) {
    super(member, pd);
    AnnotatedElement ae = (AnnotatedElement) member;
    PersistenceContext pc = ae.getAnnotation(PersistenceContext.class);
    PersistenceUnit pu = ae.getAnnotation(PersistenceUnit.class);
    Class<?> resourceType = EntityManager.class;
    if (pc != null) {
        if (pu != null) {
            throw new IllegalStateException("Member may only be annotated with either " +
                    "@PersistenceContext or @PersistenceUnit, not both: " + member);
        }
        Properties properties = null;
        PersistenceProperty[] pps = pc.properties();
        if (!ObjectUtils.isEmpty(pps)) {
            properties = new Properties();
            for (PersistenceProperty pp : pps) {
                properties.setProperty(pp.name(), pp.value());
            }
        }
        this.unitName = pc.unitName();
        this.type = pc.type();
        this.synchronizedWithTransaction = (synchronizationTypeAttribute == null ||
                "SYNCHRONIZED".equals(ReflectionUtils.invokeMethod(synchronizationTypeAttribute, pc).toString()));
        this.properties = properties;
    }
    else {
        resourceType = EntityManagerFactory.class;
        this.unitName = pu.unitName();
    }
    checkResourceType(resourceType);
}

다음에, 각 Resource에 EntityManager를 주입합니다.

@Override
protected Object getResourceToInject(Object target, String requestingBeanName) {
    // Resolves to EntityManagerFactory or EntityManager.
    if (this.type != null) {
        return (this.type == PersistenceContextType.EXTENDED ?
                resolveExtendedEntityManager(target, requestingBeanName) :
                resolveEntityManager(requestingBeanName));
    }
    else {
        // OK, so we need an EntityManagerFactory...
        return resolveEntityManagerFactory(requestingBeanName);
    }
}

최종적으로 얻어지는 EntityManager는 resolveEntityManager method를 통해서 주입되게 됩니다. EntityManager의 코드는 다음과 같습니다.

private EntityManager resolveEntityManager(String requestingBeanName) {
    // Obtain EntityManager reference from JNDI?
    EntityManager em = getPersistenceContext(this.unitName, false);
    if (em == null) {
        // No pre-built EntityManager found -> build one based on factory.
        // Obtain EntityManagerFactory from JNDI?
        EntityManagerFactory emf = getPersistenceUnit(this.unitName);
        if (emf == null) {
            // Need to search for EntityManagerFactory beans.
            emf = findEntityManagerFactory(this.unitName, requestingBeanName);
        }
        // Inject a shared transactional EntityManager proxy.
        if (emf instanceof EntityManagerFactoryInfo &&
                ((EntityManagerFactoryInfo) emf).getEntityManagerInterface() != null) {
            // Create EntityManager based on the info's vendor-specific type
            // (which might be more specific than the field's type).
            em = SharedEntityManagerCreator.createSharedEntityManager(
                    emf, this.properties, this.synchronizedWithTransaction);
        }
        else {
            // Create EntityManager based on the field's type.
            em = SharedEntityManagerCreator.createSharedEntityManager(
                    emf, this.properties, this.synchronizedWithTransaction, getResourceType());
        }
    }
    return em;
}

항시 SharedEntityManager를 이용해서 @Transactional annotation과 같이 사용할 수 있도록 동작하고 있는 것을 볼 수 있습니다. 코드를 보시면 아시겠지만, JPA를 사용하실 때는 반드시 @PersistenceContext annotation을 이용해야지 됩니다.

Posted by Y2K
,

기본적인 CRUD를 지원하는 GenericDAO를 구성하는 코드의 비교를 해볼려고 합니다.

일단 기술 배경으로는 Spring을 사용하고, Spring내에서의 Hibernate, JPA, 그리고 Hibernate를 이용한 queryDSL 코드간의 GenericDao를 비교해보고자 합니다.

기본적으로 Entity는 BaseEntity를 상속하는 것을 원칙으로 갖습니다. DB에서 생성되는 단일키를 사용하는 것으로 기본 구상을 잡고 들어가면 GenericDao의 interface는 다음과 같이 구성될 수 있습니다.

public interface EntityDao<T extends BaseEntity> {
    List<T> getAll();
    void deleteAll();
    T getById(int id);
    void add(T entity);
    void update(T entity);
    int countAll();
}

이제 이 interface를 구현한 AbstractDao class를 각 3개의 기술로 따로 구현해보고자 합니다.

Hibernate

Hibernate는 SessionFactory를 통해 생성되는 Session을 기반으로 구성합니다. Spring을 이용하기 때문에 SessionFactory.getCurrentSession() method를 이용해서 Session을 구해와서 처리합니다.

약간의 특징이라고 할 수 있는 것이 Criteria를 이용하는 코드의 경우, Class<?>를 반드시 각 EntityDao 생성 객체에서 넘겨주는 것이 필요합니다.

public abstract class AbstractSessionFactoryDao<T extends BaseEntity> implements EntityDao<T> {

    @Autowired
    protected SessionFactory sessionFactory;
    private final Class<?> clazz;

    protected AbstractSessionFactoryDao(Class<?> clazz) {
        this.clazz = clazz;
    }


    @Override
    public List<T> getAll() {
        Session session = sessionFactory.getCurrentSession();
        return session.createCriteria(clazz).list();
    }

    @Override
    public void deleteAll() {
        List<T> items = getAll();
        Session session = sessionFactory.getCurrentSession();
        for (T item : items) {
            session.delete(item);
        }
    }

    @Override
    public T getById(final int id) {
        Session session = sessionFactory.getCurrentSession();
        return (T)session.get(clazz, id);
    }

    @Override
    public void add(final T entity) {
        Session session = sessionFactory.getCurrentSession();
        session.save(entity);
    }

    @Override
    public void update(final T entity) {
        Session session = sessionFactory.getCurrentSession();
        session.update(entity);
    }

    @Override
    public int countAll() {
        Session session = sessionFactory.getCurrentSession();
        Long count = (Long) session.createCriteria(clazz)
                .setProjection(Projections.rowCount())
                .uniqueResult();

        if(count == null) {
            return 0;
        } else {
            return count.intValue();
        }
    }
}
JPA

jpa를 이용하는 경우, @PersistenceContext 를 이용해서 EntityManager를 등록해서 사용해주면 됩니다. Spring은 @PersistenceContext를 확인하면, 등록된 EntityManagerFactoryBean은 EntityManager를 사용할때마다 EntityManagerFactory에서 얻어와서 사용하게 됩니다. 이는 Spring에서 핸들링시켜줍니다. 이에 대한 코드 분석은 다음 Post에서 한번 소개해보도록 하겠습니다.

JPA를 이용한 GenericDao class의 코드는 다음과 같습니다.

public abstract class AbstractJpaDao<T extends BaseEntity> implements EntityDao<T> {

    protected AbstractJpaDao(String entityName) {
        this.entityName = entityName;
    }

    @PersistenceContext
    protected EntityManager em;

    @Autowired
    protected JpaExecutor executor;
    protected final String entityName;

    @Override
    public List<T> getAll() {
        return em.createQuery("from " + entityName).getResultList();
    }

    @Override
    public void deleteAll() {
        System.out.println(em);
        em.createQuery("delete from " + entityName).executeUpdate();
    }

    @Override
    public T getById(final int id) {
        Query query = em.createQuery("from " + entityName + " where id = :id");
        query.setParameter("id", id);
        return (T) query.getSingleResult();
    }

    @Override
    public void add(final T entity) {
        em.merge(entity);
    }

    @Override
    public void update(final T entity) {
        em.merge(entity);
    }

    @Override
    public int countAll() {
        return em.createQuery("from " + entityName).getResultList().size();
    }
}
Hibernate기반의 queryDSL

GenericDAO class를 만들기 위해서 QueryDsl에서 가장 문제가 되는 것은 QClass들의 존재입니다. Generator에서 생성되는 Q Class들은 EntityPathBase class에서 상속받는 객체들입니다. 따라서 다른 GenericDAO class 들과는 다르게 2개의 Generic Parameter를 받아야지 됩니다.

다음은 queryDSL을 이용한 GenericDAO code입니다.

public abstract class AbstractQueryDslDao<T extends BaseEntity, Q extends EntityPathBase<T>> implements EntityDao<T> {

    @Autowired
    protected SessionFactory sessionFactory;
    protected final Q q;
    protected final Class<?> clazz;

    protected AbstractQueryDslDao(Class<?> clazz, Q q) {
        this.clazz = clazz;
        this.q = q;
    }

    protected HibernateQuery getSelectQuery() {
        return new HibernateQuery(sessionFactory.getCurrentSession()).from(q);
    }

    protected HibernateDeleteClause getDeleteQuery() {
        return new HibernateDeleteClause(sessionFactory.getCurrentSession(), q);
    }

    protected HibernateUpdateClause getUpdateQuery() {
        return new HibernateUpdateClause(sessionFactory.getCurrentSession(), q);
    }


    @Override
    public List<T> getAll() {
        HibernateQuery query = getSelectQuery();
        return query.list(q);
    }

    @Override
    public void deleteAll() {
        getDeleteQuery().execute();
    }

    @Override
    public T getById(int id) {
        return (T)sessionFactory.getCurrentSession().get(clazz, id);
    }

    @Override
    public void add(T entity) {
        sessionFactory.getCurrentSession().saveOrUpdate(entity);
    }

    @Override
    public void update(T entity) {
        sessionFactory.getCurrentSession().saveOrUpdate(entity);
    }

    @Override
    public int countAll() {
        Long count = getSelectQuery().count();
        return count.intValue();
    }
}

개인적으로는 JPA에서 사용하는 JPQL의 문법이 조금 맘에 안듭니다. 거의 SQL query문을 객체를 이용해서 사용한다는 느낌만 들고 있기 때문에, 오타 등의 문제에서 자유롭지가 않습니다. 그런 면에서는 queryDSL을 이용하는 것이 가장 좋은 것 같지만 queryDSL의 경우에는 처음 초기 설정이 조금 까다롭습니다. 같이 일하는 팀원중 이런 설정을 잘 잡는 사람이 있으면 괜찮지만…

다음에는 글 내용중 언급한것처럼 @PersistenceContext Autowiring에 대해서 코드 분석을 해볼까합니다. ^^ 아무도 안오는 Blog지만요. ^^

Posted by Y2K
,

java에서 가장 대표적인 ORM인 Hibernate와 Hibernate 기반의 JPA연결 방법에 대해서 알아보도록 하겠습니다.

먼저, 모든 연결 방법은 Hibernate를 기반으로 구성되어 있습니다. 이는 Hibernate의 사상이 JPA의 기본이 되어버린 것이 큽니다. 따라서, Spring을 이용한 ORM의 접근방법이라고 하는 것이 대부분 Hibernate에 대한 연결이 되는 것이 일반적입니다.

Hibernate

Hibernate를 사용하기 위해서는 Configuration에 다음 2가지가 등록되어야지 됩니다.

  1. LocalSessionFactoryBean : SessionFactory에 대한 Factory Bean입니다. SessionFactory를 생성하는 객체를 등록시켜줍니다. 이는 Spring에서 사용할 DataSource와 Entity가 위치한 Package들에 대한 검색을 모두 포함하게 됩니다.
  2. HibernateTransactionManager : PlatformTransactionManager를 구현한 Hibernate용 TransactionManager를 등록해야지 됩니다. 이는 Spring에서 @EnableTransactionManager와 같이 사용되어 @Transactional annotation을 사용할 수 있게 됩니다.
LocalSessionFactory

SessionFactory를 생성하는 FactoryBean입니다. hibernate.cfg.xml 에서 설정되는 내용이 여기에 들어가게 됩니다.

  • Hibernate의 property를 등록합니다.
  • DataSource를 등록시킵니다.
  • Entity가 위치한 Package를 지정합니다.
    @Bean
    public LocalSessionFactoryBean sessionFactory() {
        Properties properties = new Properties();
        properties.setProperty("hibernate.dialect", env.getProperty("hibernate.dialect"));
        properties.setProperty("hibernate.show_sql", env.getProperty("hibernate.show_sql"));
        properties.setProperty("hibernate.format_sql", env.getProperty("hibernate.format_sql"));

        LocalSessionFactoryBean localSessionFactoryBean = new LocalSessionFactoryBean();
        localSessionFactoryBean.setDataSource(dataSource());
        localSessionFactoryBean.setHibernateProperties(properties);

        localSessionFactoryBean.setPackagesToScan(new String[] { "me.xyzlast.bh.entities" });
        return localSessionFactoryBean;
    }
HibernateTransactionManager

Transaction을 사용하기 위한 PlatformTransactionManager interface의 구현체를 등록합니다.

  • SessionFactory와 같이 사용될 DataSource를 등록합니다.
  • SessionFactoryBean에서 생성되는 SessionFactory를 등록시켜줍니다.
    @Bean
    public PlatformTransactionManager transactionManager() {
        HibernateTransactionManager transactionManager = new HibernateTransactionManager();
        transactionManager.setDataSource(dataSource());
        transactionManager.setSessionFactory(sessionFactory().getObject());
        transactionManager.setHibernateManagedSession(false);
        return transactionManager;
    }

JPA

Hibernate 기반의 JPA를 등록하기 위해서는 Hibernate-JPA support bean이 필요합니다. 이에 대한 maven repository의 gradle 설정은 다음과 같습니다.

    compile 'org.hibernate:hibernate-entitymanager:4.3.1.Final'

Spring에서 Hibernate기반의 JPA를 사용하기 위해서는 다음 Bean들이 필요합니다.

  1. LocalContainerEntityManagerFactoryBean : SessionFactoryBean과 동일한 역활을 맡습니다. SessionFactoryBean과 동일하게 DataSource와 Hibernate Property, Entity가 위치한 package를 지정합니다. 또한 Hibernate 기반으로 동작하는 것을 지정해하는 JpaVendor를 설정해줘야지 됩니다.
  2. HibernateJpaVendorAdapter : Hibernate vendor과 JPA간의 Adapter를 설정합니다. 간단히 showSql 정도의 Property만을 설정하면 됩니다. 매우 단순한 code입니다.
  3. JpaTransactionManager : DataSource와 EntityManagerFactoryBean에서 생성되는 EntityManagerFactory를 지정하는 Bean입니다.
LocalContainerEntityManagerFactoryBean

Hibernate에서 SessionFactoryBean과 동일한 역활을 담당하는 FactoryBean입니다. EntityManagerFactory를 생성하는 FactoryBean형태입니다. @Configuration 선언은 다음과 같습니다.

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
        LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
        entityManagerFactoryBean.setJpaVendorAdapter(hibernateJpaVendorAdapter());
        entityManagerFactoryBean.setDataSource(dataSource());
        entityManagerFactoryBean.setPackagesToScan("me.xyzlast.bh.entities");
        // NOTE : Properties를 이용해서 Hibernate property를 설정합니다.
        // entityManagerFactoryBean.setJpaProperties();
        return entityManagerFactoryBean;
    }
HibernateJpaVendorAdapter

JPA 규약과 Hibernate간의 Adapter 입니다. 특별히 설정할 내용은 존재하지 않고, showSQL 정도의 설정만이 존재합니다.

    @Bean
    public HibernateJpaVendorAdapter hibernateJpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setShowSql(true);
        return hibernateJpaVendorAdapter;
    }
JpaTransactionManager

JPA를 지원하는 TransactionManager를 등록합니다. DataSource와 EntityManagerFactory를 등록시켜주면 됩니다.

    @Bean
    public PlatformTransactionManager transactionManager() {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager(entityManagerFactory().getObject());
        jpaTransactionManager.setDataSource(dataSource());
        return jpaTransactionManager;
    }


Posted by Y2K
,

Gradle 2.0으로 업그레이드 된 버젼에 대해서 업데이트 된 글이 있습니다.


기존 gradle을 이용한 개발 환경 설정 업데이트입니다.


변경사항은 다음과 같습니다. 


1. jetty plugin의 제거 : 개발환경에서는 jetty / tomcat의 차이가 없을 것 같아, jetty를 없앴습니다.

2. spork를 이용한 테스트 환경 추가. : groovy base인 spork를 이용한 test 환경을 추가하였습니다.

3. tomcat plugin 사용시, tomcatRun 에러 수정 : tomcat plugin 사용시 tomcatRun 을 하면 servlet jar class not found 에러가 나오는 것을 수정했습니다. 

4. gradle 1.9 에서 동작하지 않던 버그를 수정했습니다.

5. build.gradle을 4개로 분리했습니다.

1) base.gradle : 기본 설정에 관련된 내용으로 구성됩니다.

2) domain.gradle : domain module에 관련된 내용으로 구성됩니다. (queryDsl QFile generate)

3) web.gradle : war에 관련된 내용으로 구성됩니다. (tomcatRun, tomcatRunWar)


build.gradle

apply plugin: 'base'
apply plugin: 'sonar-runner'
apply plugin: 'maven'
version = '1.0.0'

ext {
    javaVersion = 1.7
    springVersion = '4.0.0.RELEASE'
}


buildscript {
    repositories {
        mavenCentral()
        jcenter()
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath (group: 'org.gradle.api.plugins', name: 'gradle-cargo-plugin', version: '0.6.1')
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.1'
    }
}

allprojects {
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'

    repositories {
        mavenLocal()
        mavenCentral()
        jcenter()
        maven { url 'http://download.java.net/maven/2' }
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
        maven { url 'https://maven.java.net/content/repositories/releases'}
        maven { url 'http://repo.springsource.org/plugins-release' }
    }

    dependencies {
        provided 'org.projectlombok:lombok:0.12.0'
    }
}


apply from: 'base.gradle'
apply from: 'domain.gradle'
apply from: 'web.gradle'


base.gradle

subprojects {
    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'
    apply plugin: 'jacoco'
    apply plugin: 'groovy'

    sonarRunner {
        sonarProperties {
            property "sonar.sourceEncoding", "UTF-8"
        }
    }

    jacoco {
        toolVersion = '0.6.3.201306030806'
    }

    test {
        jacoco {
            append = false
            destinationFile = file("target/jacoco.exec")
            classDumpFile = file("target/classpathdumps")
        }
    }

    if(project.hasProperty('target')) {
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-${project.target}"]
        }
    } else {
        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }

        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-" + hostname]
        }
    }

    dependencies {
        def slf4jVersion = "1.7.2"
        compile "org.slf4j:jcl-over-slf4j:$slf4jVersion"
        compile "org.slf4j:jul-to-slf4j:$slf4jVersion"
        compile "org.slf4j:slf4j-api:$slf4jVersion"
        compile 'ch.qos.logback:logback-classic:1.0.13'

        testCompile "junit:junit:4.11"

        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
        testCompile "org.spockframework:spock-spring:1.0-groovy-2.0-SNAPSHOT"
    }

    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

    tasks.withType(Compile) {
        options.encoding = 'UTF-8'
    }
}

sonarRunner {
    sonarProperties {
        property "sonar.host.url", "http://192.168.13.209:9000"
        property "sonar.jdbc.url", "jdbc:mysql://192.168.13.209:3306/sonar"
        property "sonar.jdbc.driverClassName", "com.mysql.jdbc.Driver"
        property "sonar.jdbc.username", "root"
        property "sonar.jdbc.password", 'qwer12#$'
    }
}


domain.gradle

configure(subprojects.findAll { it.name.endsWith('domain') }) {
    dependencies {
        compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.22'
        compile("com.jolbox:bonecp:0.8.0.RELEASE") {
            exclude group: 'com.google.guava'
        }
        compile group: 'com.google.guava', name: 'guava', version: '15.0'
        compile group: 'org.springframework', name: 'spring-core', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-orm', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-tx', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context-support', version: "${rootProject.ext.springVersion}"

        compile group: 'org.hibernate', name: 'hibernate-core', version: '4.1.10.Final'
        compile group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.0-api', version: '1.0.1. Final'
        compile group: 'org.hibernate', name: 'hibernate-entitymanager', version: '4.1.10.Final'
        compile group: 'org.hibernate', name: 'hibernate-validator', version: '4.3.1.Final'

        compile group: 'org.springframework.data', name: 'spring-data-jpa', version: '1.4.2.RELEASE'

        def queryDSL = '3.2.4'
        compile("com.mysema.querydsl:querydsl-core:$queryDSL")
        compile("com.mysema.querydsl:querydsl-jpa:$queryDSL")
        compile("com.mysema.querydsl:querydsl-sql:$queryDSL")
        provided("com.mysema.querydsl:querydsl-apt:$queryDSL") {
            exclude group: 'com.google.guava'
        }

    }

    sourceSets {
        generated {
            java {
                srcDirs = ['src/main/generated']
            }
        }
    }

    task generateQueryDSL(type: JavaCompile, group: 'build', description: 'Generates the QueryDSL query types') {
        source = sourceSets.main.java
        classpath = configurations.compile + configurations.provided
        options.compilerArgs = [
                "-proc:only",
                "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
        ]
        destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
    }

    compileJava {
        dependsOn generateQueryDSL
        source generateQueryDSL.destinationDir
    }

    compileGeneratedJava {
        dependsOn generateQueryDSL
        options.warnings = false
        classpath += sourceSets.main.runtimeClasspath
    }

    clean {
        delete sourceSets.generated.java.srcDirs
    }

    idea {
        module {
            sourceDirs += file('src/main/generated')
        }
    }
}



web.gradle

configure(subprojects.findAll { it.name.endsWith('Web') }) {
    apply plugin: 'war'
    apply plugin: 'tomcat'
    apply plugin: 'cargo'

    dependencies {
        String tomcatVersion = '7.0.47'
        tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
        tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
        tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
            exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
        }

        def cargoVersion = '1.3.3'
        cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
                "org.codehaus.cargo:cargo-ant:$cargoVersion"

        providedCompile 'javax.servlet:javax.servlet-api:3.1.0'
        providedCompile 'javax.websocket:javax.websocket-api:1.0'
        providedCompile 'javax.servlet:jsp-api:2.0'
        providedCompile "org.apache.tomcat:tomcat-servlet-api:${tomcatVersion}"
    }

    tomcatRun {
        dependsOn war
    }

    tomcatRunWar {
        dependsOn war
    }

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

    cargo {
        containerId = 'tomcat7x'
        port = 8080

        deployable {
            context = "${project.name}"
        }

        remote {
            hostname = '192.168.13.209'
            username = 'ykyoon'
            password = 'qwer12#$'
        }
    }
}





Posted by Y2K
,

spock를 이용한 spring test

Java 2013. 12. 11. 03:17

드디어 JUnit의 시대가 끝이 나는것인가.... 라는 생각이 드는 멋진 테스트 Framework인 Spock를 소개합니다.


groovy 언어로 구성을 해야지 되는 언어적 제약사항이 있지만, 기존의 JUnit Test에 비해서 매우 간결한 문법과 TDD를 위한 구성을 제공하고 있습니다. 


1. 설정


gradle 기준으로 spock는 다음과 같이 설정됩니다. 


    repositories {
        mavenCentral()
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
     }
    dependencies {
        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
    }
   


기본적으로 JUnit을 사용하고 있는 test project입니다. 


2. 구성


기본적으로 JUnit과 거의 유사한 기본 설정을 갖습니다.

SpockJUnit
SpecificationTest class
setup()@Before
cleanup()@After
setupSpec()@BeforeClass
cleanupSpec()@AfterClass
FeatureTest
Parameterized featureTheory
ConditionAssertion
Exception condition@Test(expected=...)
@FailsWith@Test(expected=...)
InteractionMock expectation (EasyMock, JMock, ...)



3. 테스트 코드의 종류에 따른 구현


우리가 일반적으로 테스트 코드를 짤때, 다음과 테스트 코드가 주로 작성되게 됩니다. 


1) 특정한 method를 실행후, 객체의 상태가 예측대로인지 확인

2) 특정한 method의 input을 선정하고, output값이 예측대로 나왔는지 확인


이 두가지 case는 약간은 미묘하게 다릅니다. 1)의 경우는 response를 확인을 하는 것이고, 2)의 결과는 예측하는 것으로 약간의 차이를 가지고 오게 됩니다. 

이를 spock에서는 여러 block으로 지정하여, case에 대한 내용을 명확하게 하고 있습니다. 


spock에서 지원하는 block은 다음과 같습니다.  또한 이 block뒤에는 자유롭게 문자열을 넣어서, block안의 내용을 서술할 수 있습니다.


setup : 객체에 대한 설정

when / then : when block이 있으면 반드시 then block이 존재합니다. when에서 객체의 response를 얻어내고, then을 통해 response를 확인합니다. condition을 설정한다고 생각하시면 편합니다.

expect / where : 예측되는 값과 예측값 list를 정의합니다. spock에서 가장 놀라운 기능중 하나입니다. 


위 block을 적용한 code example은 다음과 같습니다. 


    def "건물이름 길이가 작아 exception이 발생"() {
        def buildingName = "가"
        when:
        service.searchByBuilding(buildingName)
        then:
        def e = thrown(IllegalArgumentException)
        e.message == AddressSearchServiceImpl.BUILDING_NAME_IS_TOO_SHORT
    }

    def "서울내의 레미안만 얻어오기"() {
        def buildingName = "레미안"
        when:
        def searchResults = service.searchByBuilding(SidoEnum.SEOUL.getStringValue(), buildingName, 0, 10)
        then:
        searchResults.every { r ->
            r.buildingName.contains(buildingName)
            r.siGunGu.sido.sidoNumber.equals(SidoEnum.SEOUL.getStringValue()) == true
        }
    }

    def "검색문자열 분리 테스트"() {
        expect:
        def matcher = SearchTextRegex.extractSearchText(input)
        matcher.find() == true
        matcher.group("mainText").equals(mainText) == true
        matcher.group("mainNumber").equals(mainNumber) == true

        where:
        input | mainText | mainNumber
        "구로동 245 번지" | "구로동" | "245"
        "서초1동" | "서초1동" | ""
        "잠실동 624" | "잠실동" | "624"
        "지봉로 1" | "지봉로" | "1"
    }

너무나 멋지지 않나요? 지금까지 구성하던 test code의 길이가 절반 이하로 줄어들뿐 아니라, 매우 직관적이고, TDD에 적합한 테스트 코드 형태로 구성이 되고 있는 것을 알 수 있습니다. 


개인적으로 하고 있는 project들을 모두 spock로 변경하는 작업을 한번 해봐야지 될 것 같습니다. 이런 것을 보면 왠지 희열이 느껴지지 않나요? ^^;;

모두들 Happy Coding~ 



Posted by Y2K
,