목적

Spring + Mybatis로 구성된 서비스를 운영하던 중 사용자가 몰렸을 때 DB에 부담을 덜어주기 위한 방법을 고민했다.
Mybatis 의 ExcutorType 을 Batch로 설정하면 성능이 향상 되었는데 이유를 파악하기 위해 포스트 남긴다.

결론적으로 Batch 는 statement를 재사용 하며 Create, Insert, Update를 벌크로 모아서 처리하고, 
Select를 만나면 기존에 모아놓았던 쿼리들을 수행하기 때문에 C, I, U 작업은 연달아서 할 때 가장 효율적이었다.
트랜잭션과, select문이 끼어드는 위치를 잘 조정하여 사용하면  DB의 부담을 덜 수 있을것으로 보인다. 

Batch Processing? - wiki

일괄 처리(batch processing)란 컴퓨터 프로그램 흐름에 따라 순차적으로 자료를 처리하는 방식을 뜻한다.
일괄 처리 시스템(batch system)이란 일괄처리(batch processing) 방식이 적용된 시스템으로서, 하나의 작업이 끝나기 전까지는 다른 작업을 할 수 없다.

장점 
  • 많은 사용자 사이에서 컴퓨터 자원을 공유할 수 있다.
  • 작업 프로세스의 시간대를 컴퓨터 리소스가 덜 사용되는 시간대로 이동한다.
  • 분 단위의 사용자 응답 대기와 더불어 컴퓨터 리소스의 유휴 사용을 피한다.
  • 전반적인 이용률을 높임으로써 컴퓨터의 비용을 더 잘 상환하도록 도와 준다.

JDBC에서 Batch Processing 

JDBC 2.0는 batch proccessing 은 다음과 같이 제공한다.
1. Prepare statement.
2. Bind parameters.
3. Add to batch.
4. Repeat steps 2 and 3 until interest has been assigned for each account.
5. Execute.

preparedStatement를 사용한 Batch Processing

PreparedStatement stmt = conn.prepareStatement(
    "UPDATE account SET balance = ? WHERE acct_id = ?");"UPDATE account SET balance = ? WHERE acct_id = ?");
int[] rows;

for(int i=0; i<accts.length; i++) {
    accts[i].calculateInterest( );
    stmt.setDouble(1, accts[i].getBalance( ));
    stmt.setLong(2, accts[i].getID( ));
    stmt.addBatch( );
}
rows = stmt.executeBatch( );
  • addBatch() : create, insert, update, deleted 일괄 처리 결과를 관리 할 필요가 없기 때문에 batch에 statement를 추가하는 일만 한다.
  • excuteBatch()가 쿼리들을 수행하고 정수형 배열을 리턴하는데, 각 배열의 요소는 각 statement로 인해 update된 row의 숫자이다. 

batch모드로 수행할 때 auto-commit을 사용하면 각각의 statement 를 수행 후 commit이 발생한다.
그래서 batch 수행 중 에러가 발생하면 에러 발생 전까지 statement는 DB 에 반영되어있고 BatchUpdateException에서 getUpdateCounts()로 성공한 쿼리의 숫자를 얻어 올 수 있다.
실제 배치 과정에서, 각 statement 마다 commit을 하면 배치의 성능이 떨어지게 되는데 이것을 트랜잭션으로 묶어서 관리하려고 해도 성능에 악영향을 미친다. 가장 좋은 방법은 임의의 수 만큼 statement 를 묶어서, 명시적으로 commit 하는 것 같다. 


Mybatis Batch Mode

Mybatis는 SqlSessionFactory에서 SqlSessionTemplate(SqlSession 구현체)을 생성할 때 ExcutorType을 지정해 줄 수 있다.

ExcutorTypeDescription
SIMPLEexecutor does nothing special 
REUSEexecutor reuses prepared statements 
BATCHexecutor reuses statements and batches updates


ExecutorType.BATCH : 이 실행자는 모든 update(C,I,U,D)구문을 배치처리하고 중간에 select 가 실행될 경우 필요하다면 경계를 표시한다.

여기서 이 경계는 아래 소스코드를 통해 확인 할 수 있다. 


SqlSessionTemplate에서 ExecutorType이 BATCH인 경우에 Create, Insert, Update, Delete 구문은 doUpdate 에서, Select 구문은 doQuery 에서 처리한다.

아래 코드의 59번 줄에서 statement를 재사용하는 로직을 확인 할 수 있고, 61번, 69번 줄에서 statement 재사용 여부에 따라 statementList에 statement를 추가하고 63번, 70번 줄에서 파라미터 추가, 다른 구문일 경우 BatchResult를 추가하는 것을 볼 수 있다.

그리고 doQuery 메소드의 81번줄에서 flushStatements()를 호출 하는데, 이는 그동안 statementList에 쌓아 왔던 statement를  executeBatch() 메소드로 수행한다. 수행 결과는 batchResult에 저장하여 return 하고, 그동안 쌓아왔던 statementList 와 batchResultList 를 초기화한다.

아래 코드처럼 Select문이 실행 될 경우 BATCH 처리를 멈추고 초기화 하기 때문에, BATCH 작업 도중 SELECT 문이 끼어들면 성능이 저하된다. 


org.apache.ibatis.executor.BatchExecutor.class - grepcode

40 public class More ...BatchExecutor extends BaseExecutor {
41
42   public static final int BATCH_UPDATE_RETURN_VALUE = Integer.MIN_VALUE + 1002;
43
44   private final List<Statement> statementList = new ArrayList<Statement>();
45   private final List<BatchResult> batchResultList = new ArrayList<BatchResult>();
46   private String currentSql;
47   private MappedStatement currentStatement;
48
49   public More ...BatchExecutor(Configuration configuration, Transaction transaction) {
50     super(configuration, transaction);
51   }
52
53   public int More ...doUpdate(MappedStatement ms, Object parameterObject) throws SQLException {
54     final Configuration configuration = ms.getConfiguration();
55     final StatementHandler handler = configuration.newStatementHandler(this, ms, parameterObject, RowBounds.DEFAULT, null, null);
56     final BoundSql boundSql = handler.getBoundSql();
57     final String sql = boundSql.getSql();
58     final Statement stmt;
59     if (sql.equals(currentSql) && ms.equals(currentStatement)) {
60       int last = statementList.size() - 1;
61       stmt = statementList.get(last);
62       BatchResult batchResult = batchResultList.get(last);
63       batchResult.addParameterObject(parameterObject);
64     } else {
65       Connection connection = getConnection(ms.getStatementLog());
66       stmt = handler.prepare(connection);
67       currentSql = sql;
68       currentStatement = ms;
69       statementList.add(stmt);
70       batchResultList.add(new BatchResult(ms, sql, parameterObject));
71     }
72     handler.parameterize(stmt);
73     handler.batch(stmt);
74     return BATCH_UPDATE_RETURN_VALUE;
75   }
76
77   public <E> List<E> More ...doQuery(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql)
78       throws SQLException {
79     Statement stmt = null;
80     try {
81       flushStatements();
82       Configuration configuration = ms.getConfiguration();
83       StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameterObject, rowBounds, resultHandler, boundSql);
84       Connection connection = getConnection(ms.getStatementLog());
85       stmt = handler.prepare(connection);
86       handler.parameterize(stmt);
87       return handler.<E>query(stmt, resultHandler);
88     } finally {
89       closeStatement(stmt);
90     }
91   }
92
93   public List<BatchResult> More ...doFlushStatements(boolean isRollback) throws SQLException {
94     try {
95       List<BatchResult> results = new ArrayList<BatchResult>();
96       if (isRollback) {
97         return Collections.emptyList();
98       } else {
99         for (int i = 0, n = statementList.size(); i < n; i++) {
100          Statement stmt = statementList.get(i);
101          BatchResult batchResult = batchResultList.get(i);
102          try {
103            batchResult.setUpdateCounts(stmt.executeBatch());
104            MappedStatement ms = batchResult.getMappedStatement();
105            List<Object> parameterObjects = batchResult.getParameterObjects();
106            KeyGenerator keyGenerator = ms.getKeyGenerator();
107            if (Jdbc3KeyGenerator.class.equals(keyGenerator.getClass())) {
108              Jdbc3KeyGenerator jdbc3KeyGenerator = (Jdbc3KeyGenerator) keyGenerator;
109              jdbc3KeyGenerator.processBatch(ms, stmt, parameterObjects);
110            } else if (!NoKeyGenerator.class.equals(keyGenerator.getClass())) { //issue #141
111              for (Object parameter : parameterObjects) {
112                keyGenerator.processAfter(this, ms, stmt, parameter);
113              }
114            }
115          } catch (BatchUpdateException e) {
116            StringBuffer message = new StringBuffer();
117            message.append(batchResult.getMappedStatement().getId())
118                .append(" (batch index #")
119                .append(i + 1)
120                .append(")")
121                .append(" failed.");
122            if (i > 0) {
123              message.append(" ")
124                  .append(i)
125                  .append(" prior sub executor(s) completed successfully, but will be rolled back.");
126            }
127            throw new BatchExecutorException(message.toString(), e, results, batchResult);
128          }
129          results.add(batchResult);
130        }
131        return results;
132      }
133    } finally {
134      for (Statement stmt : statementList) {
135        closeStatement(stmt);
136      }
137      currentSql = null;
138      statementList.clear();
139      batchResultList.clear();
140    }
141  }
142
143}

 기타, 참조


용어

SqlSessionTemplate - http://www.mybatis.org/spring/ko/sqlsession.html
SqlSessionTemplate은 마이바티스 스프링 연동모듈의 핵심이다.
SqlSessionTemplate은 SqlSession을 구현하고 코드에서 SqlSession를 대체하는 역할을 한다.
SqlSessionTemplate 은 쓰레드에 안전하고 여러개의 DAO나 매퍼에서 공유할수 있다.
getMapper()에 의해 리턴된 매퍼가 가진 메서드를 포함해서 SQL을 처리하는 마이바티스 메서드를 호출할때 SqlSessionTemplate은 SqlSession이 현재의 스프링 트랜잭션에서 사용될수 있도록 보장한다.
추가적으로 SqlSessionTemplate은 필요한 시점에 세션을 닫고, 커밋하거나 롤백하는 것을 포함한 세션의 생명주기를 관리한다.
또한 마이바티스 예외를 스프링의 DataAccessException로 변환하는 작업또한 처리한다.
SqlSessionTemplate은 마이바티스의 디폴트 구현체인 DefaultSqlSession 대신 항상 사용된다.
왜냐하면 템플릿은 스프링 트랜잭션의 일부처럼 사용될 수 있고 여러개 주입된 매퍼 클래스에 의해 사용되도록 쓰레드에 안전하다.
동일한 애플리케이션에서 두개의 클래스간의 전환은 데이터 무결성 이슈를 야기할수 있다.

참조

Batch Processing - https://ko.wikipedia.org/wiki/%EC%9D%BC%EA%B4%84_%EC%B2%98%EB%A6%AC

Batch Processing(JDBC) - https://www.safaribooksonline.com/library/view/database-programming-with/1565926161/ch04s02.html

Batch Processing(JDBC) - http://www.tutorialspoint.com/jdbc/jdbc-batch-processing.htm

Mybatis3 - http://www.mybatis.org/mybatis-3/ko/index.html

Grep Code(org.apache.ibatis.executor.BatchExecutor.class) - http://grepcode.com/file/repo1.maven.org/maven2/org.mybatis/mybatis/3.2.7/org/apache/ibatis/executor/BatchExecutor.java#BatchExecutor

1. 개요


서비스에 대용량의 트래픽이 몰리면서, 특정 API 호출에 대한 응답이 지연되면 전체적 로직에 영향을 미치게 된다.

이러한 상황을 재현하기 위해 응답을 늦게 보내는 서버를 구현 하고자 했다.

단순하게 요청을 받고 몇초간의 sleep 후 응답을 돌려주면 간단하게 구현할 수 있지만, 

제한된 자원(톰캣 1대)에서 다수(초당 15000명)의 request 를 처리하기 위해서 일반적인 sleep 방법으로는 구현 할 수 없었다.

(성능 이슈가 없다면 이곳 을 참조하면 간단하게 응답 지연 api를 얻을 수 있다.)


2. 문제점


사실, response time은 늦으면서, 다수의 request를 처리한다는 건 역설적인 말이다. 

request 가 많을 수록, connection 의 수는 늘어 날 것이고, sleep 을 통해 connection을 놓아주지 않으면  더 이상의 request 를 처리할 수 없기 때문이다. 

request 를 위한 connection 비용이 적지 않을 뿐더러, Spring 에서 request 를 처리하기 위한 servlet container thread 자원은 application의 로직을

수행하기 위한 application thread 보다 적고 한정적이다. 


3. 해결 방법


Long polling 방식으로 request 에 대하여 늦은 response를 전달하는 방법으로 서버 성능 문제를 해결하고자 했다.

단순히 클라이언트 쪽에서 long polling 방식은 connection을 그대로 유지하고 있기 때문에 문제를 해결 할 수 없는 방식이었고,

서버쪽에서 long polling 을 적용하여 connection 위한 thread 관리가 필요했다.


사실 long polling 방식도 request - response 모델이어서, 서버에서 클라이언트에게 응답을 주기 전까지 connection을

유지하기 때문에 이 방식도 성능 이슈를 해결하기 어려울 것 처럼 보였다. 

그러던 중 이러한 long polling의 문제점을 보완하기 위해 Spring 3.2에서 추가된 Async Servlet 기능을 보게 되었다. 

Async Servlet는 request 처리를 위한 servlet container thread의 일을 application thread에 위임시키고 servlet container thread를 반납해서

새로운 request 를 처리하는 방식으로 작동한다.


Spring 의 Async Servlet 을 이용하면, 서버의 thread pool 과 acceptCount 값을 조정하여, 

제한된 자원에서 다수의 request 를 처리할 수 있을것이라 생각했다.


4. 적용


소스 코드는 Spring-mvc-showcase 를 참조하였다. 


(1) 소스

@Controller @RequestMapping("/async/callable") public class CallableController { @RequestMapping("/response-body-2") public @ResponseBody Callable<String> callable() { return new Callable<String>() { @Override public String call() throws Exception { Thread.sleep(2000); return "Callable result"; } }; } }



(2) 결과

응답이 2초 후 온 것을 알 수 있다.


위 로직을 수행하는 동안의 로그는 다음과 같다.

15:23:20 [http-apr-80-exec-9] DispatcherServlet - DispatcherServlet with name 'appServlet' processing GET request for [/async/callable/response-body-2]

15:23:20 [http-apr-80-exec-9] RequestMappingHandlerMapping - Looking up handler method for path /async/callable/response-body-2

15:23:20 [http-apr-80-exec-9] RequestMappingHandlerMapping - Returning handler method [public java.util.concurrent.Callable<java.lang.String> org.springframework.samples.mvc.async.CallableController.callable()]

15:23:20 [http-apr-80-exec-9] DispatcherServlet - Last-Modified value for [/async/callable/response-body-2] is: -1

여기 까지 기존 로직을 따른다

15:23:20 [http-apr-80-exec-9] WebAsyncManager - Concurrent handling starting for GET [/async/callable/response-body-2]

WebAsyncManager(application thread) 에서 병행처리를 시작하고

15:23:20 [http-apr-80-exec-9] DispatcherServlet - Leaving response open for concurrent processing

Dispatcher(servlet container thred) 에서 작업을 끝낸다.

이렇게 작업 쓰레드간 switching 이 이루어지고

15:23:22 [MvcAsync5] WebAsyncManager - Concurrent result value [Callable result] - dispatching request to resume processing

작업을 넘겨받은 MvcAsyc 는 작업을 수행(2초간의 sleep)후 dispatching 을 요청한다.

15:23:22 [http-apr-80-exec-4] DispatcherServlet - DispatcherServlet with name 'appServlet' resumed processing GET request for [/async/callable/response-body-2]

15:23:22 [http-apr-80-exec-4] RequestMappingHandlerMapping - Looking up handler method for path /async/callable/response-body-2

15:23:22 [http-apr-80-exec-4] RequestMappingHandlerMapping - Returning handler method [public java.util.concurrent.Callable<java.lang.String> org.springframework.samples.mvc.async.CallableController.callable()]

15:23:22 [http-apr-80-exec-4] DispatcherServlet - Last-Modified value for [/async/callable/response-body-2] is: -1

15:23:22 [http-apr-80-exec-4] RequestMappingHandlerAdapter - Found concurrent result value [Callable result]

15:23:22 [http-apr-80-exec-4] RequestResponseBodyMethodProcessor - Written [Callable result] as "text/html" using [org.springframework.http.converter.StringHttpMessageConverter@7967f612]

Spring mvc가 callable 을 보고 병행작업임을 알고 나머지 로직을 수행한다.

15:23:22 [http-apr-80-exec-4] DispatcherServlet - Null ModelAndView returned to DispatcherServlet with name 'appServlet': assuming HandlerAdapter completed request handling

15:23:22 [http-apr-80-exec-4] DispatcherServlet - Successfully completed request



5. Servlet container thread 와 application specific thread 의 차이


여기서 servlet container thread 와 application thread 의 차이점을 살펴보자.

servlet thread의 일을 application thread 로 넘겨줌으로 해서 long polling 의 단점을 보완한다고 하는데, 결국 application thread 역시

서버의 자원을 사용하지 않는가? 라는 의문을 가질 수 있다.


Servlet container 란 HTTP Request 와 response 객체를 서블릿에 넘겨주고, 서블릿의 생명주기를 관리한다.

따라서 servlet container thread 는 사용자의 request 를 처리하는 작업을 수행한다. 

반면 application는 서비스 자체를 의미하고, application thread는 Spring framework 에서 발생하는 작업들을 수행한다.


이러한 관점에서 보았을때,  serblet thread 의 일을 application thread 에 위임하는 것이 

많은 request 를 수신하기 위한 효율 적인 방법임을 알 수 있다.

하지만, 결국 한정된 서버의 자원을 공유하여 사용한다는것은 변함이 없다.

spring mvc 내에서 servlet container에 할당 할 thread 수를 조정할 수 있는지 알아보고 최적화가 필요할 것 같다.


6. 결론


서버의 한정된 자원에서 다수의 request 를 처리하는데 한계가 있지만, 

서버는 무엇으로 구성할지 (톰캣, 네티, 직접구현)에 따라서, 그리고 서버 환경 설정을 어떻게 하는냐에 따라 동시에 수용할 수 있는 request의 수를 증가 시킬 수 있다. 

서버의 설정 이후에는, 어플리케이션의 구현 부분에서 최적화가 필요할 것이다.

고민의 시작은 전자에서 시작했지만, 작업은 후자에서 진행되었다.

다시 서버단으로 돌아가서 고민을 해봐야겠지만, 넉넉한 자원이 주어졌을때, 해당 자원을 제대로 활용하지 못하는 경우

application의 구현부분에서의 최적화 역시 필요할것으로 생각한다.


결과적으로  Async Servlet를 이용한 long polling 방식으로 서버에 요청할 수 있는 최대 request 를 늘리는 것은 실패하였다.

추가적으로 서버의 최적화 또는, tomcat 에 comet 을 적용, 심플한 구조의 서버 구축과 같은 작업이 병행되어야 할 것으로 보인다.

 


Flyway?

데이터베이스 형상관리 툴.

로컬에서 변경한 데이터베이스의 스키마나 데이터를 운영 데이터베이스에 반영하는 것을 누락하는것을 막기 위해 사용한다.

또한 개발 DB와 운영 DB의 스키마를 비교하거나, 운영 DB에 수작업을 가하는 노가다와 위험성을 줄이기 위해 사용한다.

생성한 형상을 새로운 DB에 적용하면 그게 마이그레이션이다.



 

동작방식

Flyway 가 연결된 데이터베이스에 자동으로 SCHEMA_VERSION 이라는 메타데이터 테이블을 생성한다.

Flyway 는 사용자가 정의한 sql의 파일명을 자동으로 스캔하여, SCHEMA_VERSION 에 버전 정보를 남기는 동시에,

데이터베이스에 변경내용을 적용한다.

SCHEMA_VERSION 테이블에는 다음과 같은 정보를 담고 있다.

 


명령어 설명

init - SCHEMA_VERSION 을 baseline 과 함께 생성한다. 테이블이 이미 생성되어 있으면 수행되지 않는다.

migrate - 스키마정보를 리얼DB에 마이그레이션한다.

clean - flyway로 생성한 스키마를 모두 삭제한다고 하지만, 해당 데이터 베이스의 모든 테이블을 삭제한다. 

info - DB에 적용된 스키마 정보와, 로컬에 pending 되어있는 변경 정보를 보여준다.

validate - DB에 적용된 스키마 정보와, 로컬의 변경점을 비교하여 보여준다.

repair - 마이그레이션 실패한 내역을 수정한다 (삭제, 교체)

baseline - flyway로 형상 버전관리를 시작 할 baseline 을 설정한다.

Command-line tool 로 예제 실행

(1) 설치

http://flywaydb.org/getstarted/firststeps/commandline.html

.

(2) 설정 

설치 폴더 -> conf -> flyway.con 파일 수정 (C:\flyway\conf\flyway.conf)

flyway.url=jdbc:mysql://localhost:3306/테이블명
flyway.user=아이디
flyway.password=비번

.

(3) 마이그레이션 sql문 생성 

설치 폴더 -> sql (C:\flyway\sql) 에 파일 생성.

init을 이용하여 SCHEMA_VERSION 테이블을 생성하면 V1 로 생성되기 때문에 파일명을 V2 로 생성함.

V2__Create_person_table.sql 

create table PERSON ( 
    ID int not null, 
    NAME varchar(100) not null 
);create table PERSON ( 
    ID int not null, 
    NAME varchar(100) not null 
);                                                                            

V2.1__Insert_person.sql

insert into PERSON (ID, NAME) values (1, 'Axel'); 
insert into PERSON (ID, NAME) values (2, 'Mr. Foo'); 
insert into PERSON (ID, NAME) values (3, 'Ms. Bar');

파일명은 V 와 숫자로 버전명을 지정하고 under_bar 두개로 시작되어야한다. 이미지참조.


  

.

(4) flyway init 명령문 수행

flyway init 없이 migrate를 수행하면, 자동으로 schema_version이 생성된다.

flyway init 을 수행하면 결과 화면과 같이 baseline 이 V1 로 입력되어있는것을 확인 할 수 있다.

명령문 수행화면

데이터베이스 적용 결과

(5) flyway migrate 명령문 수행

flyway가 sql 파일들을 스캐닝하여 수행한다.

V2 와 V2.1 의 sql 파일이 버전 순서대로 수행된 것을 확인 할 수 있다.

명령문 수행화면

데이터베이스 적용결과

.

(6) 마이그레이션 sql문 추가 생성

V2.2__Insert_another_person.sql 

insert into PERSON (ID, NAME) values (5, 'CLEAN!!!!');

.

(7) flyway validate 명령문 수행

migrate 없이, flyway validate 를 수행하면 새로 추가한 쿼리문에 실제 DB에 적용되어 있지않기 때문에,

fail이 떨어진다.

명령문 수행화면

.

(8) flyway info 명령문 수행

flyway info 로  내용을 확인해 보면 2.2 버전의 파일이 pending 상태인것을 알 수 있다.

명령문 수행화면

.

(9) flyway clean 명령문 수행

flyway clean 을 수행하면 y/n 질문도 없이 데이터베이스의 모든 테이블들이 드랍된다.

flyway로 작업된 테이블만 드랍된다는 말이 있는데, 아니다 모두 드랍된다.

명령문 수행화면

생각

전체적으로 사용하기 쉽고 대부분의 툴과 연동이 가능한 것이 장점.

데이터 베이스의 스키마 변경 이력을 확인 할 수 있다는 점이 가장 좋았음.

각자 로컬 데이터 베이스를 이용하여 개발하지 않으면 형상관리와 마이그레이션의 의미가 약해짐.

개발 DB 를 따로 운영함으로서, 작업중 스키마 변경에 따른 이슈가 발생 할 수 있지만,

스키마 이력관리를 위해, Flyway 를 적용하는 것은 오버스팩이라고 생각함. 

데이터베이스의 스키마를 변경하면 이력을 남기는 워크벤치쪽 플러그인이 있으면 좋겠다고 생각함.

참조

http://flywaydb.org/

+ Recent posts