본문 바로가기
개발/Spring

[Spring] @Async를 사용하여 비동기 처리 (+ 쓰기, 읽기 서비스 분리)

by baau 2023. 8. 12.

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비동기-메소드-사용하기

https://steady-coding.tistory.com/611

https://www.baeldung.com/spring-async