SWM 프로젝트를 진행하면서, 비동기 처리를 통해서 효율적으로 요청을 처리하고, 많은 도서 데이터의 확보를 위한 고민을 하였습니다.
따라서 해당 포스트에서는 Spring @Async에 대해서 정리하고, 실제 적용한 부분에 대해서 설명하고자 한다.
비동기 처리가 필요한 이유
- 도서 상세 페이지 조회 시, 도서 정보가 도서 DB에 저장되어 있지 않으면 알라딘 API를 호출하여 도서 상세 페이지 응답을 처리한다.
- 이때, 비동기 처리를 통해 알라딘 API를 통해 조회된 데이터를 도서 DB에 저장하는 작업을 수행한다.
- 도서 DB에 도서 데이터를 저장하고, 도서 상세 페이지를 보여줄 경우, 도서 DB에 병목이 있을 경우 도서 상세 페이지를 사용자에게 띄어주는 것에 영향을 줄 수 있다고 생각한다.
- 따라서 비동기 처리를 통해서 사용자에게 도서 상세 페이지를 띄어주되, 비동기로 해당 데이터를 도서 DB에 저장할 수 있도록 구현한다.
@Async
Spring에서 제공하며, Thread Pool을 활용하는 비동기 메서드 지원 Annotation
- @EnableAsync 추가
@EnableAsync
@SpringBootApplication
public class SpringBootApplication {
...
}
- 비동기로 작동하길 원하는 method 위에 @Async 어노테이션 추가
public class GillogAsync {
@Async
public void asyncMethod(final String message) throws Exception {
....
}
}
실제, @Async는 SimpleAsyncTaskExecutor를 사용한다. 따라서 프로젝트의 환경에 맞게 AsyncConfigurerSupport를 상속받는 Class를 커스텀하는 것을 추천한다.
@Configuration
@EnableAsync
public class AsyncConfig extends implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("MYBRARY-ASYNC-");
executor.initialize();
return executor;
}
}
- @Configuration : Spring 설정 관련 Class로 @Component 등록되어 Scanning 될 수 있다.
- @EnableAsync : Spring method에서 비동기 기능을 사용가능하게 활성화 한다.
- CorePoolSize : 기본 실행 대기하는 Thread의 수
- MaxPoolSize : 동시 동작하는 최대 Thread의 수
- QueueCapacity : MaxPoolSize 초과 요청에서 Thread 생성 요청 시, 해당 요청을 Queue에 저장하는데 이때 최대 수용 가능한 Queue의 수, Queue에 저장되어 있다가 Thread에 자리가 생기면 하나씩 빠져나가 동작
- ThreadNamePrefix : 생성되는 Thread 접두사 지정
위의 설정 정보를 바탕으로 아래와 같이 동작할 수 있다.
최초 3개의 스레드(corePool)에서 처리하다가 처리 속도가 밀릴 경우 작업을 100개 사이즈 Queue
(QueueCapacity)에 넣어 놓고, 그 이상의 요청이 들어오면 최대 30개의 스레드(maxPool)를 생성해
서 작업을 처리하게 된다.
@Async 주의 사항
- private method 사용 불가
AOP가 적용되어, @Async가 적용된 method의 경우 Spring이 method를 가로채 다른 Thread에서 실행시켜주는 동작 방식.
따라서
- self-invocation (자가 호출) 불가, 즉 inner method 사용 불가
같은 Class에 존재하는 method가 @Async 어노테이션을 작성한 메서드를 호출하면 비동기 처리가 되지 않고 동기적으로 동작한다.
따라서 @Async가 붙어 있는 메서드를 정상적으로 비동기 처리하기 위해서는 같은 클래스에 있는 메서드가 아닌 다른 클래스에 있는 메서드가 호출해야 한다.
💡 위 두 가지 주의 사항은 @Async가 Spring AOP를 통해서 동작하기 때문에 AOP의 제약 사항을 따르는 것과 동일하다.
- private 메서드에 @Async를 붙여도 AOP가 동작하지 않는다.
- 같은 객체 내의 메서드끼리 호출할 시 AOP가 동작하지 않는다.
- @Transactional도 위와 같은 제약 사항을 따른다.
- QueueCapacity 초과 요청에 대한 비동기 method 호출 시 방어 코드 작성
AsyncConfigurerSupport를 상속하여 AsyncConfig를 커스텀할 때, 아래 설정 코드를 통해서 최대 수용 가능한 Queue의 수를 설정하는데, ThreadPool 수와 QueueCapacity 수까지 초과된 요청일 경우 TaskRejectedException이 발생한다.
executor.setQueueCapacity(10);
따라서 아래와 같이 TaskRejectedException이 발생 시, 핸들링하는 방어코드를 작성해야 한다.
try {
for(int i=0; i<50; i++) {
testService.asyncMethod(i);
} catch (TaskRejectedException e) {
// ....
}
}
@Async 메서드 테스트 코드 작성하기
- @Async를 비활성화하고 동기적으로 테스트하기
@EnableAsync(proxyTargetClass = true)를 사용하여 @Async를 활성화하고, 테스트 메서드를 @Async가 적용되지 않은 일반적인 메서드처럼 동기적으로 호출
- 테스트 코드 내에서 비동기 로직을 완료될 때까지 잠시 대기시켜, 비동기 로직을 포함한 테스트 코드를 작성 👍🏻
@Test
public void testAsyncMethod() {
MyAsyncService myAsyncServiceMock = mock(MyAsyncService.class);
taskExecutor.execute(() -> {
myAsyncServiceMock.asyncMethod();
});
await().untilAsserted(() -> {
verify(myAsyncServiceMock).asyncMethod();
});
}
await(). untilAsserted를 통해서 비동기 처리가 끝날 때까지 기다렸다가 검증한다.
대기 시간을 결정하는 요소에 대해서는 Chat-GPT는 다음과 같이 설명하였다.
비동기 메서드의 실행 시간: 테스트되는 비동기 메서드의 예상 실행 시간을 고려해야 합니다. 만약 비동기 메서드가 빠르게 실행되어 바로 결과를 반환한다면, 대기 시간은 상대적으로 짧을 수 있습니다. 그러나 시간이 오래 걸리는 비동기 작업의 경우, 대기 시간을 늘려야 합니다.
비동기 메서드의 복잡성: 비동기 메서드가 더 많은 복잡한 작업을 처리하는 경우, 실행에 더 많은 시간이 소요될 수 있으므로 대기 시간을 늘려야 합니다.
비동기 메서드가 호출되는 위치: 테스트 메서드 내부에서 비동기 메서드를 호출하는 경우, 테스트가 진행되는 동안 다른 비동기 작업이 실행되어 테스트 결과에 영향을 줄 수 있으므로 적당한 대기 시간을 추가해야 합니다.
테스트 환경: 테스트 환경의 성능과 부하에 따라 비동기 메서드의 실행 속도가 달라질 수 있습니다. 테스트 환경에 따라 대기 시간을 조정해야 합니다.
일반적으로 비동기 메서드의 실행 시간이 예측 가능하다면, 해당 시간보다 조금 더 긴 대기 시간을 설정하는 것이 좋습니다. 그러나 정확한 대기 시간을 결정하는 것은 각 테스트 상황에 따라 다르므로, 실험과 경험을 통해 최적의 대기 시간을 찾아야 합니다.
적용 코드
AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("MYBRARY-ASYNC-");
executor.initialize();
return executor;
}
}
BookReadService.java
public BookDetailServiceResponse getBookDetailByISBN(BookDetailServiceRequest request) {
return bookRepository.findByISBNWithAuthorAndCategoryUsingFetchJoin(request.getIsbn10(), request.getIsbn13())
.map(book -> {
BookDetailServiceResponse response = BookDtoMapper.INSTANCE.bookToDetailServiceResponse(book);
response.isInterestedBookByLoginUser(book.isInterestedByLoginUser(request.getLoginId()));
return response;
})
.orElseGet(() -> {
BookSearchDetailResponse bookSearchDetailResponse = platformBookSearchApiService.searchBookDetailWithISBN(
BookSearchServiceRequest.of(request.getIsbn13()));
bookWriteService.create(BookDtoMapper.INSTANCE.bookSearchDetailToBookCreateServiceRequest(bookSearchDetailResponse));
return BookDtoMapper.INSTANCE.bookSearchDetailToDetailServiceResponse(bookSearchDetailResponse);
});
}
BookWriteService.java
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void create(BookCreateServiceRequest request) {
checkBookAlreadyRegistered(request);
Book book = BookDtoMapper.INSTANCE.bookCreateRequestToEntity(request);
List<BookAuthor> bookAuthors = request.getAuthors().stream()
.map(r -> getAuthor(r.getAuthorId(), r.getName()))
.map(author -> BookAuthor.builder().author(author).build())
.toList();
List<BookTranslator> bookTranslators = request.getTranslators().stream()
.map(r -> getTranslator(r.getTranslatorId(), r.getName()))
.map(translator -> BookTranslator.builder().translator(translator).build())
.toList();
book.addBookAuthor(bookAuthors);
book.addBookTranslator(bookTranslators);
book.assignCategory(getBookCategory(request.getCategoryId(), request.getCategory()));
bookRepository.save(book);
}
- 도서 상세 보기 시, 데이터베이스에 해당 도서 정보가 없으면, 외부 API를 통해서 도서 정보를 조회하고, 비동기로 해당 데이터를 데이터베이스에 저장하도록 구현하였습니다.
+ Read, Write 서비스 분리
기존에는 BookReadService와 BookWriteService가 따로 분리되어있지 않고, BookService였다.
하지만, @Async는 같은 클래스 내에서 동작하지 않기 때문에, 클래스를 분리해야 하는 상황이 있었다. 불가피한 상황이라고 생각했지만, WriteService와 ReadService를 분리함으로써, 쓰기 작업과 읽기 작업의 성능을 각각 높일 수 있는 기회라는 생각도 들었다.
쓰기 작업 Service와 읽기 작업 Serive를 분리함으로써 얻을 수 있는 장점은 다음과 있을 것이라 기대한다.
- 읽기 작업과 쓰기 작업은 서로 다른 요구 사항을 가지고 있다.
- 읽기 작업에서는 데이터를 변경하지 않기 때문에 트랜잭션 격리 수준을 낮추거나 읽기 복제 등의 최적화 기법을 적용할 수 있고, 쓰기 작업은 데이터의 일관성이 중요하기 하기 때문에 더 높은 격리 수준이 필요할 수도 있다.
- 읽기 전용 트랜잭션 등과 같이 트랜잭션을 더욱 정밀하게 조절할 수 있다.
- 가독성 및 유지보수성 향상을 기대할 수 있다.
- 서비스 계층을 쓰기 작업과 읽기 작업으로 명확하게 분리함으로써 가독성 있고 유지보수가 용이해질 수 있다.
- 변경 사항이 생겼을 때, 해당 서비스만 수정하면 되므로 다른 서비스에 영향을 주지 않는다.
참고 레퍼런스
https://velog.io/@gillog/Spring-Async-Annotation비동기-메소드-사용하기
'개발 > Spring' 카테고리의 다른 글
[Spring] MyBatis 사용법 및 동적 쿼리 정리 (1) | 2024.01.09 |
---|---|
[Spring] @Cacheable를 통해 성능 개선하기 (캐시) (2) | 2023.09.04 |
[Spring] JPA Fetch Join 사용시, MultipleBagFetchException 발생 (3) | 2023.08.03 |
[Test] 외부 API 테스트하기 (+ RestTemplateBuilder) (0) | 2023.07.06 |
[Spring] Spring Rest Docs로 API 문서 자동화하기 (0) | 2023.07.03 |