요번 글에서 다루고자 하는 점은
- Spring 에서 API 처리 시
- Asynchronous 한 Hibernate DB 작업을 하고
- 모든 결과물을 wait 해서 함께 처리한 뒤 내려줘야 하는 상황
3가지 요소를 다 처리하는 방법에 대한 고민입니다.
메인페이지에 보여주는 여러가지 위젯을 한번에 내려줘야 하는 상황이었는데, 위젯 1개마다 복잡한 쿼리를 날려 DB 데이터를 가져와야 하는 상황이라 순차적으로 수행하면 7초가 넘게 걸리고 있었습니다. 그래도 이 작업들을 동시에 모든 위젯을 불러온 뒤, 한번에 내려 주는 방식으로 처리하고자 위 3가지 요소가 필요해졌습니다.
첫번째 시도, CompletableFuture
- concurrency 나 future 에 대해 익숙하지 않으시다면 아래 글등을 읽어보시면 좋습니다.
Java 8: CompletableFuture in action
회사에서 개발중인 소스코드 중에는 비동기 처리 로직을 위해 CompletableFuture
를 사용하고 있는 부분들이 있습니다. Mybatis
세션을 이용하는 경우, 별 문제 없이 해당 작업을 CompletableFuture.runAsync()
를 이용해서 여러 task 를 돌린 뒤, CompletableFuture.allOf(futureTaskArray).join()
을 이용해 동시에 수행하고 결과를 기다렸다가 받아 올 수 있었습니다.
동일한 방법을 이번 작업에 사용해 보았지만, 기존의 통합검색이 Mybatis
session 을 이용한 것과 달리위젯부분은 Hibernate
를 사용하고 있었고, Hibernate
는 기본적으로 Synchronized-transactionalsession 이 필요하기에 오류가 발생합니다
Caused by: org.hibernate.HibernateException: Could not obtain transaction-synchronized Session for current thread at org.springframework.orm.hibernate4.SpringSessionContext.currentSession(SpringSessionContext.java:134) at org.hibernate.internal.SessionFactoryImpl.getCurrentSession(SessionFactoryImpl.java:1014) at com.healing.beauty.dao.BaseDao.getCurSession(BaseDao.java:44)
저희 hibernate 는 미리 만들어 둔 BaseDao 라는 부모 Dao 클래스에서 session 을 잡아다 사용하게 되어있는데, 해당 작업시 SpringSessionContext
에서는 아래와 같이 synchorize 한 트랜잭션을 가지고 있는지를 체크하게 됩니다.
if (TransactionSynchronizationManager.isSynchronizationActive()) { Session session = this.sessionFactory.openSession(); if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { session.setFlushMode(FlushMode.MANUAL); } SessionHolder sessionHolder = new SessionHolder(session); TransactionSynchronizationManager.registerSynchronization( new SpringSessionSynchronization(sessionHolder, this.sessionFactory, true)); TransactionSynchronizationManager.bindResource(this.sessionFactory, sessionHolder); sessionHolder.setSynchronizedWithTransaction(true); return session; } else { throw new HibernateException("Could not obtain transaction-synchronized Session for current thread"); }
결국 completableFuture 를 쓸 경우, 이 부분을 통과하지 못했습니다.
2. @Async
어노테이션 사용
Async 를 사용하기 위한 전제조건은 상세한 설명들이 링크에 있으니 참고바랍니다
How To Do @Async in Spring | Baeldung
@Async
는 간단히 말하자면, proxy 를 사용하여 비동기처리를 수행하는 방식이기 때문에, 무조건 같은 서비스내 호출이 아닌 다른 서비스 혹은 컨트롤러에서 서비스 호출등으로 프록시를 거치게 해야 합니다. 그래서 기존에 WidgetService
내에서 setting 하는 부분을 떼어내서 WidgetAsyncService
를 추가해서 메소드를 만들어주고 위에 어노테이션을 붙였습니다.
@Async public Future<Void> setDataV3(Widget widget, WidgetFilter filter) { logger.info("========== async STARTTTT ========== " + widget.getTitle()); /*** 어쩌구 저쩌구 위젯을 가지고 타입별로 Dao 등과 연계해서 필요한 데이터를 불러오는 기능 ***/ logger.info("========== async ENDDDDDDDDDD ========== " + widget.getTitle()); return new AsyncResult<>(null); }
그리고 수행했는데 결과가 똑같…. 또 동기화된 트랜잭션이 없답니다.
그래도 아까와 달리 이번엔 spring annotation 을 이용한 task executor 를 사용하고 있기에, 여기에 Transaction Propagation 을 적용해보았습니다. (참고로 compleatable future 를 사용할때는 propagation 을 적용해도 변화가 없었다.)
3. @Async
+ @Transactional(Propagation.NOT_SUPPORTED)
기본적으로 해당 프로젝트의 모든 @Service 에는 @Transactional 이 걸려있습니다. 그렇기에 모든 서비스들이 트랜잭션 내에서 수행되고 있다고 생각하면 됩니다. 그런데 @async 는 기본적으로 ThreadLocal
에서 벗어나 수행되면서 Synchronize 한 트랜잭션을 가지지 못하게 되고 그러면서 hibernate 수행이 되지 않았습니다.
그래서 Propagation 룰을 바꿔주기로 했습니다. (propagation 을 모르신다면 아래 멋진 분들의 멋진 글들을 확인)
이 케이스에서는 Propagation.NEVER
는 트랜잭션을 전달하지 않기에 위와 같은 오류가 날 것이므로, NOT_SUPPORTED
나 REQUIRED_NEW
가 필요한데, 어차피 조회만 하는 작업이기에 부모-자식 트랜잭션 상관없기에 NOT_SUPPORTED
로 하고 , readOnly
도 true 로 주었습니다.
// 비동기로 처리하는 부분 WidgetAsyncService 내. @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) @Async public Future<Void> setDataV3(Widget widget, WidgetFilter filter) { logger.info("========== async STARTTTT ========== " + widget.getTitle()); /*** 어쩌구 저쩌구 위젯을 가지고 타입별로 Dao 등과 연계해서 필요한 데이터를 불러오는 기능 ***/ logger.info("========== async ENDDDDDDDDDD ========== " + widget.getTitle()); return new AsyncResult<>(null); }
이번엔 성공한 코드이기 때문에 비동기를 부르는 동기부분 서비스까지도 추가!
비동기 동작 확인
비동기 처리가 제대로 돌아가는지 보기 위해선 꼭 로깅이 필요합니다. 비동기 처리 부분이 제대로 동기 프로세스와 별개로 수행 되는지 보기 위해 비동기 메소드 수행 전/후, 비동기 메소드 안 시작과 끝에 로그를 추가했습니다
@Transactional public PagingResult<Widget> getWidgetListByV3(WidgetFilter filter) { // dao 에 접속해 먼저 위젯 리스트 불러오기 PagingResult<Widget> result = getDao().getListBy(filter); // 비동기 처리의 결과를 담을 array List<Future> futures = new ArrayList<>(); logger.info(" ============== 111"); // 위젯마다 필요한 데이터 세팅 for(Widget widget : result.getData()) { logger.info("========== method start ========== " + widget.getTitle()); futures.add(widgetAsyncService.setDataV3(widget, filter)); logger.info("========== method end ========== " + widget.getTitle()); } logger.info(" ============== 222"); // 완료대기 for(Future future : futures) { try { // AsyncResult 와 같은 Future object 들은 get 을 이용해 결과 값을 받을때까지 wait 할 수 있다. // 이 예제에선 void 형태의 리턴값 없는 비동기 메소드였지만, 결과 값이 필요한 경우 비동기메소드의 return 에서 AsyncResult 에 넣어주고 // 아래 Future.get() 을 이용해 해당 값을 받아내서 처리할 수 있다. future.get(); } catch(ExecutionException e) { e.printStackTrace(); } catch(InterruptedException e) { e.printStackTrace(); } } logger.info(" ============== 333 "); // 위젯 필터링 및 정렬 List<Widget> finalList = sortAndFilterWidget(result.getData()); result.setData(finalList); logger.info(" ============== 444 "); return result; }
만약, 비동기로 수행하는 부분이 결과값도 필요없는 경우라면 (count 를 올리거나, push 를 보내거나 하는 api response 에 필요한 부분이 아니라면) 위에 처럼 get 을 해서 굳이 결과값을 기다릴 필요 없이 바로 return 해버리면 됩니다.
자 아무튼 그래서 위처럼 수행하면 예상되는 결과는 아래처럼 로그로 나옵니다.
//로그 내용이 많아 축약했습니다. 2019-02-03 02:20:25.234 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [160] : ============== 111 2019-02-03 02:20:25.242 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [164] : ========== method start ========== 위젯 타이틀 A 2019-02-03 02:20:25.244 INFO --- [executor-task-19] c.example.service.WidgetAsyncService [ 80] : ========== async STARTTTT ========== 위젯 타이틀A 2019-02-03 02:20:25.244 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [166] : ========== method end ========== 위젯 타이틀 A 2019-02-03 02:20:25.249 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [164] : ========== method start ========== 위젯 타이틀 B 2019-02-03 02:20:25.250 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [166] : ========== method end ========== 위젯 타이틀 B 2019-02-03 02:20:25.251 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [164] : ========== method start ========== 위젯 타이틀 C 2019-02-03 02:20:25.251 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [166] : ========== method end ========== 위젯 타이틀 C 2019-02-03 02:20:25.252 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [170] : ============== 222 2019-02-03 02:20:25.252 INFO --- [executor-task-19] c.example.service.WidgetAsyncService [ 80] : ========== async ENDDDDDDDDDD ========== 위젯 타이틀 A 2019-02-03 02:20:25.255 INFO --- [ executor-task-7] c.example.service.WidgetAsyncService [ 80] : ========== async STARTTTT ========== 위젯 타이틀 C 2019-02-03 02:20:25.255 INFO --- [ executor-task-1] c.example.service.WidgetAsyncService [ 80] : ========== async STARTTTT ========== 위젯 타이틀 B 2019-02-03 02:20:26.552 INFO --- [ executor-task-1] c.example.service.WidgetAsyncService [293] : ========== async ENDDDDDDDDDD ========== 위젯 타이틀 B 2019-02-03 02:20:26.674 INFO --- [ executor-task-7] c.example.service.WidgetAsyncService [293] : ========== async ENDDDDDDDDDD ========== 위젯 타이틀 C 2019-02-03 02:20:26.674 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [182] : ============== 333 2019-02-03 02:20:26.675 INFO --- [-nio-8080-exec-5] c.example.service.WidgetService [187] : ============== 444
111 부분이 순차적으로 수행되고, 비동기 메소드가 위젯 숫자만큼 실행되는데, 메소드 전후의 로그들이 비동기이기에 바로 순서대로 쭉 실행 되면서 루프가 끝나면 바로 222가 찍힙니다. 그와 동시에 위젯 내용을 불러서 세팅하는 메소드들이 동시 다발적으로 Async 작업을 시작하고 개별적으로 끝나게 됩니다.
즉 222를 찍는 부분과 각 위젯내용을 비동기로 불러오는 부분은 서로 병렬하게 진행됩니다.
단, 333 이전에 모든 future 작업을 get 해서 처리하기 때문에 333은 모든 async method end 이후에 찍히게 될 것이고 , 그 후 정렬 작업등 뒤에 444 가 찍힌 뒤 모든 작업이 끝나고 결과물을 내려주게 됩니다.
위 로그를 보면 [executor-task-19] 처럼 Async 에서 사용하는 비동기 태스크의 번호를 볼 수 있습니다. Async 사용 이전에 Config 세팅으로 동시 사용할 수 있는 executor 의 숫자 라던가, 해당 작업이 hibernate 와 DB 작업 이기에 connection pool 개수 등을 너무 작게 세팅해 두었다면 동시에 할 수 있는 작업이 한계가 있어서 로그가 예상과 다르게 찍힐 수 도 있습니다. (실제로 첫번째 시도했을 때 커넥션풀 개수가 5개 였는데 조회된 위젯이 10개였어서 5개 작업만 동시에 수행 되었습니다.)
마무리
위 방법이 가장 좋은 방법이라기보단, 적절하게 성공한 유일한 방법이라서 정리해 보았습니다. 좀더 Future
클래스와 Transaction session 에 대한 깊이가 있다면 다른 방법으로도 사용이 가능할지 모르겠습니다.
어쨌든, 동시다발적으로 DB 에 붙어서 필요한 데이터를 조회하고 , 해당 결과를 모아서 다시 api response 로 내려줄 필요가 있다면 이와 비슷한 방식으로 해결할 수 있습니다.
결과적으로 이전에 7초 8초 정도 걸리던 작업은 1초 만에 해결되었습니다. 거기에 추가적으로 캐싱까지 걸어서 5분에 1번만 1초정도 걸리고 나머지 요청들은 200ms 정도만에 처리되게 변경하였습니다. 끝!