잊지 않겠습니다.

HikariCP 소개

Java 2014. 8. 21. 17:10

HikariCP

BoneCP를 재치고 놀라운 속도를 자랑하는 DB Connection Pool입니다. BoneCP의 경우, Hibernate 4.x 버젼에서의 지원이 조금 애매해진 경향이 있습니다. (최신 버젼의 Hibernate에서는 에러가 발생합니다.) 반면에 HikariCP의 경우에는 Hibernate와의 통합 jar가 나오는 등, 계속해서 밀어주고 있다는 느낌이 강하게 듭니다.

GitHub page

https://github.com/brettwooldridge/HikariCP

Hibernate + HikariCP

build.gradle에 다음 dependency들을 추가합니다.

    compile 'org.slf4j:slf4j-api:1.7.5'
    compile 'com.zaxxer:HikariCP:2.0.1'
    compile 'org.javassist:javassist:3.18.2-GA'

hibernate.cfg.xml 파일에 다음 항목을 추가합니다.

<property name="connection.provider_class">com.zaxxer.hikari.hibernate.HikariConnectionProvider</property>
<property name="hibernate.hikari.dataSourceClassName">com.mysql.jdbc.jdbc2.optional.MysqlDataSource</property>
<property name="hibernate.hikari.dataSource.url">jdbc:mysql://localhost/test</property>
<property name="hibernate.hikari.dataSource.user">root</property>
<property name="hibernate.hikari.dataSource.password">qwer12#$</property>
<property name="hibernate.hikari.dataSource.cachePrepStmts">true</property>
<property name="hibernate.hikari.dataSource.prepStmtCacheSize">250</property>
<property name="hibernate.hikari.dataSource.prepStmtCacheSqlLimit">2048</property>
<property name="hibernate.hikari.dataSource.useServerPrepStmts">true</property>

Spring + HikariCP

@Configuration을 이용해서 HikariCP를 사용하는 법은 다음과 같습니다.

DataSource를 이용하는 경우 (ex: mssql server)
    @Bean(destroyMethod = "shutdown")
    public DataSource dataSource() {
        HikariDataSource dataSource = new HikariDataSource();

        dataSource.setUsername(env.getProperty(CONNECT_USERNAME));
        dataSource.setPassword(env.getProperty(CONNECT_PASSWORD));

        dataSource.setDataSourceClassName("com.microsoft.sqlserver.jdbc.SQLServerDataSource");
        dataSource.addDataSourceProperty("url", env.getProperty(CONNECT_URL));

        int minConnection = Integer.parseInt(env.getProperty(CONNECT_MIN));
        dataSource.setMinimumIdle(minConnection);
        int maxConnection = Integer.parseInt(env.getProperty(CONNECT_MAX));
        dataSource.setMaximumPoolSize(maxConnection);

        return dataSource;
    }
Driver를 이용하는 경우 (ex: mssql server)
    @Bean(destroyMethod = "shutdown")
    public DataSource dataSource() {
        HikariDataSource dataSource = new HikariDataSource();

        dataSource.setUsername(env.getProperty(CONNECT_USERNAME));
        dataSource.setPassword(env.getProperty(CONNECT_PASSWORD));
        dataSource.setDriverClassName(env.getProperty(CONNECT_DRIVER));
        dataSource.setJdbcUrl(env.getProperty(CONNECT_URL));

        int minConnection = Integer.parseInt(env.getProperty(CONNECT_MIN));
        dataSource.setMinimumIdle(minConnection);
        int maxConnection = Integer.parseInt(env.getProperty(CONNECT_MAX));
        dataSource.setMaximumPoolSize(maxConnection);

        return dataSource;
    }

HikariCP property

HikariCP의 property 설정은 다음과 같습니다.

autoCommit (default : true)

connection이 종료되거나 pool에 반환될 때, connection에 속해있는 transaction을 commit 할지를 결정합니다.

readOnly (default : false)

database connection을 readOnly mode로 open합니다. 이 설정은 database에서 지원하지 않는다면 readOnly가 아닌 상태로 open되기 때문에, 지원되는 database 목록을 확인해보고 사용해야지 됩니다.

transactionIsolation (default : none)

java.sql.Connection 에 지정된 Transaction Isolation을 지정합니다. 지정된 Transaction Isoluation은 다음과 같습니다.

  • Connection.TRANSACTION_NONE : transaction을 지원하지 않습니다.
  • Connection.TRANSACTION_READ_UNCOMMITTED : transaction이 끝나지 않았을 때, 다른 transaction에서 값을 읽는 경우 commit되지 않은 값(dirty value)를 읽습니다.
  • Connection.TRANSACTION_READ_COMMITTED : transaction이 끝나지 않았을 때, 다른 transaction에서 값을 읽는 경우 변경되지 않은 값을 읽습니다.
  • Connection.TRANSACTION_REPEATABLE_READ : 같은 transaction내에서 값을 또다시 읽을 때, 변경되기 전의 값을 읽습니다. TRANSACTION_READ_UNCOMMITTED 와 같이 사용될 수 없습니다.
  • Connection.TRANSACTION_SERIALIZABLE : dirty read를 지원하고, non-repeatable read를 지원합니다.

기본값을 각 Driver vendor의 JDBCDriver에서 지원하는 Transaction Isoluation을 따라갑니다. (none으로 설정시.)

category (default : none)

connection에서 연결할 category를 결정합니다. 값이 설정되지 않는 경우, JDBC Driver에서 설정된 기본 category를 지정하게 됩니다.

connectionTimeout(default: 30000 - 30 seconds)

connection 연결시도시 timeout out값을 설정합니다. 이 시간내로 connection을 연결하는데 실패하면 SQLException을 발생합니다.

idleTimeout(default : 600000 - 10 minutes)

connection Pool에 의하여 확보된 connection의 maximum idle time을 결정합니다. connection Pool에 의하여 확보된 connection이 사용되지 않고, Pool에 의해서만 이 시간동안 관리된 경우, connection을 DB에 반환하게 됩니다. 값을 0으로 설정하는 경우, 확보된 connection을 절대 반환하지 않습니다.

maxLifetime(default : 1800000 - 30 minutes)

connection Pool에 의하여 확보된 connection의 최대 생명주기를 지정합니다. connection을 얻어낸지, 이 시간이상되면 최근에 사용하고 있던 connection일지라도, connection을 close시킵니다. 사용중에 있던 connection은 close 시키지 않습니다. (사용이 마쳐지면 바로 close 됩니다.) HikariCP에서는 이 값을 30~60 minutes 사이의 값을 설정하라고 강력권고합니다. 값을 0로 설정하는 경우 lifetime은 무제한이 됩니다.

leakDetectionThreshold (default : 0)

connectionPool에서 반환된 connection의 올바른 반환이 이루어졌는지를 확인하는 thread의 갯수를 지정합니다. 이 값을 0로 지정하는 경우, leak detection을 disable 시키게 됩니다. 만약에 또다른 connection pool을 사용하고 있다면, 다른 connection pool에서 만들어진 connection을 leak으로 판단하고 connection을 닫아버릴 수 있습니다.

jdbc4ConnectionTest (default : true)

connection을 맺은다음, Connection.isValid() method를 호출해서 connection이 정상적인지를 확인합니다. 이 property는 다음에 나올 connectionTestQuery에 매우 밀접한 영향을 받습니다.

connectionTestQuery (default : none)

Connection.isValid() method를 지원하지 않는 ‘legacy’ database를 위한 빠른 query를 지정합니다. (ex: VALUES 1) jdbc4ConnectionTest가 더 유용하기 때문에 사용하지 않는 것이 좋습니다.

connectionInitSql (default : none)

새로운 connection이 생성되고, Pool에 추가되기 전에 실행될 SQL query를 지정합니다.

dataSourceClassName (default : none)

JDBC driver에서 지원되는 dataSourceClassName을 지정합니다. 이 값은 driverClassName이 지정된 경우, 지정할 필요가 없습니다.

dataSource (default : none)

사용자가 만든 dataSource를 Pool에 의하여 wrapped하는 것을 원하는 경우, 이 값을 지정하여 사용합니다. HikariCP는 이 문자열을 이용해서 reflection을 통해 dataSource를 생성합니다. 이 값이 설정되는 경우, dataSourceClassName, driverClassName 과 같은 값들은 모두 무시 됩니다.

driverClassName

HikariCP에서 사용할 DriverClass를 지정합니다. 이 값이 지정되는 경우, jdbcUrl이 반드시 설정되어야지 됩니다.

jdbcUrl

jdbcUrl을 지정합니다. driverClassName이 지정된 경우, jdbcUrl을 반드시 지정해줘야지 됩니다.

minimumIdle (default : maximumPoolSize)

connection Pool에서 유지할 최소한의 connection 갯수를 지정합니다. HikariCP에서는 최고의 performance를 위해 maximumPoolSize와 minimumIdle값을 같은 값으로 지정해서 connection Pool의 크기를 fix하는 것을 강력하게 권장합니다.

# maximumPoolSize

connection Pool에서 사용할 최대 connection 갯수를 지정합니다. 이 부분은 운영환경과 개발환경에 매우 밀접하게 연결되는 부분으로, 많은 테스트 및 운영이 필요합니다.

username

Connection을 얻어내기 위해서 사용되는 인증 이름을 넣습니다.

password

username과 쌍이 되는 비밀번호를 지정합니다.

poolName (default : auto-generated)

logging과 JMX management에서 지정할 pool의 이름을 지정합니다.

registerMbeans (default : false)

JMX management Beans에 등록되는 될지 여부를 지정합니다.

Posted by Y2K
,

Spring @Transactional에 대하여.

DB에 대한 Transaction의 경계를 잡는데에 유용하게 쓰이는 Spring의 @Transaction은 Proxy mode와 AspectJ mode로 동작합니다. 일반적으로 많이 사용하는 것은 Proxy mode를 주로 사용하는데, Proxy mode를 사용하게 되는 경우, 다음과 같은 주의점이 필요합니다.

private method의 @Transaction 미적용 문제

이는 어찌보면 당연한 결과입니다. 우리는 Service 객체를 Proxy를 통해서 얻어오고, 얻어온 객체를 접근할 때 Proxy에 의하여 Transaction이 시작되기 때문에 당연히 이런 상황에서는 Transaction이 적용되지 못합니다.
이 문제는 큰 문제가 될 수 없습니다. 다만, 다음 문제는 조금 생각해봐야지 되는 문제가 발생하게 됩니다.

Transaction이 적용되지 않은 method에서 Transaction이 적용된 method를 호출할때.

소위 말하는 self call의 경우 입니다. 이 경우는 어떻게 될까요? 다음 code를 통해서 확인해보도록 하겠습니다.

먼저, 2개의 public method를 준비합니다.

public interface BookService {
    void doNotTransactionAction();
    void doTransactionAction();
}

그리고, 이에 대한 서비스 구현 코드를 다음과 같이 구성합니다.

    @Override
    public void doNotTransactionAction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.isNull(TransactionSynchronizationManager.getCurrentTransactionName());
    }

    @Transactional(readOnly = false)
    @Override
    public void doTransactionAction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.notNull(TransactionSynchronizationManager.getCurrentTransactionName());
    }

코드 내용은 매우 단순합니다. method가 호출되면 TransactionSynchronizationManager에서 현 Transaction의 이름을 확인하는 코드입니다. 첫번째 doNotTrancationAction에서는 당연히 Transaction이 없어야지 되고, 두번째 doTransactionAction에서는 당연히 Transaction이 있어야지 될 것으로 생각할 수 있습니다. 이제 이 두 코드를 호출하는 테스트 코드를 작성해서 시험해보면 다음과 같은 결과를 보입니다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = DomainConfiguration.class)
public class BookServiceImplTest {
    @Autowired
    private BookService bookService;

    @Before
    public void setUp() {
        assertThat(bookService, is(not(nullValue())));
    }

    @Test
    public void doNotTransactionAction() {
        bookService.doNotTransactionAction();
    }

    @Test
    public void doTransactionAction() {
        bookService.doTransactionAction();
    }
}

매우 당연한 결과이지만, 이 결과를 이제 수정해서 다음 코드로 바꾸어보도록 하겠습니다. Transaction이 적용되지 않은 code에서 Transaction이 적용된 method를 호출하는 경우에 어떻게 진행되는지 보도록 하겠습니다.

    @Override
    public void doNotTransactionAction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.isNull(TransactionSynchronizationManager.getCurrentTransactionName());
        doTransactionAction();
    }

결과는 다음과 같이 Assert Error가 나타나게 됩니다.

java.lang.IllegalArgumentException: [Assertion failed] - this argument is required; it must not be null
    at org.springframework.util.Assert.notNull(Assert.java:112)
    at org.springframework.util.Assert.notNull(Assert.java:123)
    at me.xyzlast.bookstore.services.BookServiceImpl.doTransactionAction(BookServiceImpl.java:54)
    at me.xyzlast.bookstore.services.BookServiceImpl.doNotTransactionAction(BookServiceImpl.java:47)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)

왜 이런 결과를 나타내게 될까요? 이는 Spring Transaction이 Proxy로 동작하기 때문입니다. Public으로 Service interface에 접근할 때, Transaction이 동작하기 때문에 발생하는 에러입니다. 이 문제는 보다 더 복잡한 문제를 야기할 수 있습니다. 다음과 같은 코드를 확인해보도록 하겠습니다.

    @Transactional(readOnly = false)
    @Override
    public void doTransactionAction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.notNull(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.isTrue(!TransactionSynchronizationManager.isCurrentTransactionReadOnly());
    }

    @Transactional(readOnly = true)
    @Override
    public void doTransactionReadonlyAction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.notNull(TransactionSynchronizationManager.getCurrentTransactionName());
        Assert.isTrue(TransactionSynchronizationManager.isCurrentTransactionReadOnly());
        doTransactionAction();
    }

2개의 Transaction action이 있습니다. 하나는 ReadOnly로 동작하고, 나머지 하나는 Not ReadOnly입니다. 그런데 ReadOnly method에서 Not ReadOnly method를 내부에서 호출하게 되면 어떻게 될까요. 결과는 Not ReadOnly method 내부에서도 Transaction은 ReadOnly로 동작하게 됩니다. 이는 매우 심각한 Transaction문제를 가지고 오게 됩니다.

Summary

Spring의 @Transactional annotation은 매우 훌륭합니다. 다만 이를 PROXY mode에서 사용할 때는 다음과 같은 원칙을 알고 사용해야지 됩니다.

  1. private method에서는 동작하지 않음
  2. transaction이 적용되지 않은 public method 내부에서 transaction이 적용된 public method를 호출하는 경우, transaction이 동작하지 않는다.
  3. readonly transaction이 적용된 public method 내부에서 not-readonly transaction이 적용된 public method를 호출하는 경우, 모든 method는 readonly transaction으로 동작하게 된다.
  4. (3)의 경우는 반대로도 적용된다.


Posted by Y2K
,

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
,

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

  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
,

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


Posted by Y2K
,

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




Posted by Y2K
,