TRACE/SSAFYCIAL

[SSA이다 #11] Spring에서의 비동기 처리

을숲 2024. 10. 28.

Spring에서의 비동기 처리

 

 안녕하세요! SSAFYcial 이예림입니다. SSAFY에서 여러 프로젝트를 진행하다보니, Spring 환경에서도 비동기 처리를 해줘야 하는 상황이 생깁니다. 각각의 기능, 비즈니스 로직에 따라 동기보다는 비동기를 적용하는 게 성능 상 유리하기도 합니다. 그렇다면 어떻게 Spring에서 비동기 처리를 해줄 수 있을지 알아보겠습니다! 

 

 

 1. 비동기 처리를 사용하는 이유

 비동기 처리의 목적은 응답 시간을 단축하고 자원을 효율적으로 사용하는 것입니다. 동기(Synchronous) 방식에서는 작업이 끝날 때까지 기다려야 하기 때문에 다른 작업을 수행할 수 없습니다. 그렇지만, 비동기(Asynchronous) 방식에서는 메인 스레드가 작업의 종료를 기다리지 않고 다른 요청을 처리할 수 있습니다. 

 

 (1) 응답 시간 단축

 첫번째로는, 응답시간이 줄어듭니다. 

 예를 들어, 외부 API를 호출하거나 데이터베이스 작업, Websocket 통신이 이뤄질 때는 해당 작업이 끝날 때까지 대기해야 하므로, 비동기 처리를 해주게 되면 클라이언트 응답 시간을 단축할 수 있습니다. 특히, 파일을 업로드하고 다운로드하는 I/O 작업이 많은 경우에는 비동기 처리가 유용합니다. 

 

 (2) 자원 관리 효율

 두번째로, 자원 관리를 효율적으로 할 수 있습니다. 

 동기 방식에서는 스레드를 많이 생성하게 되면서, 시스템 자원을 많이 사용합니다. 반대로, 비동기 처리 방식에서는 작업이 끝날 때까지 스레드를 차지하지 않기 때문에 시스템 자원을 효율적으로 사용할 수 있습니다. 이를 통해 서버의 확장성을 높일 수 있어 많은 요청을 처리할 수 있답니다. 

 

 

 2. 비동기 처리 시 고려해야 할 점

 그렇지만 무작정 응답 시간을 단축하기 위해 비동기 처리를 도입하면 안됩니다. 사용할 때 아래 사항을 주의하면서 도입해야 최대의 효율을 낼 수 있습니다. 

 

 (1) 코드 복잡도

 비동기 처리를 하게 되면, 여러 개의 스레드가 동시에 실행됩니다. 이를 관리하기 위해 어쩔 수 없이 코드가 복잡해지게 됩니다. 그만큼 디버깅이 어려워질 수 있습니다. 

 

 (2) 동시성 문제

 이렇게 여러 스레드가 동시에 실행되고, 다 같이 동시에 접속하는 경우 같은 데이터를 처리하여도 결과가 일치하지 않는 경우가 발생할 수 있습니다. 그러니 이러한 것을 고려하여 코드를 작성해야 합니다.

 

 (3) 메모리 문제 

 스레드를 생성하고 관리하는 데에도 어느 정도의 오버헤드가 발생합니다. 또, 스레드 풀로 스레드를 생성하면 그 스레드는 다른 스레드와 동일한 메모리 공간을 공유하게 되는데, 이로 인해 메모리 문제가 발생할 수도 있습니다! 따라서 처리량이 많아지면 오히려 전체 성능이 떨어질 수 있다고 합니다. 

 

 

 3. Spring에서의 비동기 처리 방법

 (1) Spring Boot Async

 Spring Boot에서도 자체적으로 비동기 프로그래밍을 지원합니다. 이를 통해 메소드 호출은 즉시 반환되고, 실제 작업은 별도 스레드에서 비동기적으로 실행할 수 있습니다. 

 @Async는 Spring 3.0부터 지원되는 비동기 처리 어노테이션입니다. 이 어노테이션을 통해 비동기를 적용해줄 수 있는데,  먼저 @EnableAsync를 통해 비동기 처리를 활성화 해준 후, 해당 함수에 어노테이션을 붙여주면 됩니다. 

 

@Service
public class AsyncService {
    private static final Logger logger = LoggerFactory.getLogger(AsyncService.class);

    @Async
    public CompletableFuture<String> asyncMethod() throws InterruptedException {
        logger.info("Start async method: {}", Thread.currentThread().getName());
        Thread.sleep(2000);  
        logger.info("End async method: {}", Thread.currentThread().getName());
        return CompletableFuture.completedFuture("Async result");
    }
}

 

 (2) Executor

 Spring에서는 Executor를 통해 비동기 작업을 설정하고 관리할 수 있습니다. 이렇게 Executor를 사용하면 직접 스레드를 관리하지 않아도 비동기 작업을 실행할 수 있어 코드의 가독성과 유지보수성을 높여줍니다.

 

 Spring에서는 기본적으로 SimpleAsyncTaskExecutor를 통해 비동기 작업을 처리해줍니다. SimpleAsyncTaskExecutor는 Spring Framework에 내장되어 있습니다. 각각의 작업을 새로운 스레드에서 실행하여 스레드 풀을 사용하지 않습니다. 따라서 다수의 작업을 동시에 실행할 수 있긴 하지만, 스레드 수가 많아질 경우 시스템의 리소스를 과도하게 사용할 수 있습니다. 

 

 그렇기 때문에, ThreadPoolTaskExecutor를 사용하기도 합니다. ThreadPoolTaskExecutor는 java.util.concurrent 패키지에 포함된 클래스 중 하나인데, 스레드 풀을 사용하여 비동기 작업을 효율적으로 처리해줍니다. 그렇기 때문에, 다수의 작업을 동시에 실행하면서도 시스템 리소스를 최적화할 수 있습니다. 

 

 아래와 같이 AsyncConfig에서 각각의 executor를 정의하고, 메소드에서 해당 executor를 명시해서 사용할 수 있습니다. 

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    // SimpleAsyncTaskExecutor 설정
    @Bean(name = "simpleAsyncTaskExecutor")
    public Executor simpleTaskExecutor() {
        return new SimpleAsyncTaskExecutor();
    }

    // ThreadPoolTaskExecutor 설정
    @Bean(name = "threadPoolTaskExecutor")
    public Executor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3); // 최소 스레드 수
        executor.setMaxPoolSize(5);  // 최대 스레드 수
        executor.setQueueCapacity(10); // 큐 크기
        executor.setThreadNamePrefix("MyThread-");
        executor.initialize();
        return executor;
    }
}

 

@Service
public class AsyncService {

    @Async("simpleAsyncTaskExecutor")
    public void simpleAsyncMethod() {
        System.out.println("Executing simple async task in thread: " + Thread.currentThread().getName());
    }

    @Async("threadPoolTaskExecutor")
    public void threadPoolAsyncMethod() {
        System.out.println("Executing thread pool async task in thread: " + Thread.currentThread().getName());
    }
}

 

 

 (3) 비동기 처리 반환

 이렇게 비동기 작업이 끝나면, 그 결과를 처리하기 위해 Future, ListenableFuture, CompletableFuture 같은 반환 타입을 사용할 수 있습니다. 

 

 Future은 비동기 작업의 결과를 나중에 받아오기 위해 사용되지만, get() 호출 시 블로킹이 발생할 수도 있습니다. 

 

 ListenableFuture은 Spring 4.0 버전부터 지원되는 인터페이스입니다. 이 인터페이스는 콜백을 통해, 비동기 작업이 완료된 후 처리 작업을 지정할 수 있습니다. 

 

 CompletableFuture은 더 복잡한 비동기 흐름을 처리할 수 있습니다. 체이닝과 예외 처리 등이 가능해, 더욱 유연한 비동기 처리가 가능해집니다. 

 

 

 4. 서블릿 기반 비동기 처리

 Servlet 3.0에서부터, 서블릿 스레드를 비동기적으로 처리할 수 있도록 지원합니다. 비동기 작업이 시작되면 즉시 서블릿 스레드를 반납하고, 완료되면 다시 스레드가 할당됩니다. HTTP Connection은 Non-Blocking이지만 요청을 읽고 응답을 쓰는 과정에서의 I/O 작업은 여전히 Blocking 방식으로 동작할 수 있단 단점이 존재했습니다. 그렇지만 Servlet 3.1에서 서블릿 요청, 응답에도 Non-blocking 처리가 이뤄졌습니다. Servlet 4.0에서는 HTTP/2가 지원되기 시작했고, Servlet 6.0에서는 API 종속성이 없는 전체 쿠키, URL 보안 보호 등의 기능이 추가되었습니다. 

 

 Spring에서는 Callable, DeferredResult, ResponseBodyEmitter 등을 통해 비동기 서블릿을 사용할 수 있습니다. 

 

 DeferredResult는 Spring 3.2 버전 이후로 지원되는 비동기 처리 기술로, '지연된 결과'를 의미합니다. 즉, 응답을 나중에 반환할 수 있습니다. 소켓 통신 없이도 간단한 채팅방이나, 대기 상태로 있는 여러 대상들에게 결과를 쏴주어야 할 때 유용합니다. 가장 중요한 점은, 워커 스레드가 별도로 만들어지지 않는다는 점입니다. 

 

 ResponseBodyEmitter는 Spring 4.2부터 지원되는 기술로, 비동기 요청의 결과를 여러번 나누어 전달할 때 사용되는 기술입니다. StreamingResponseBody의 경우 파일 Byte 데이터를 Stream으로 나누어 전달도 가능하고, SseEmitter를 사용하면 Server-Sent-Events(SSE) 기술 적용도 가능해집니다. (SSE: 클라이언트 측에서 폴링을 따로 사용하지 않고, HTTP 커넥션을 통해 서버에서 이벤트 발생 시 클라이언트 측으로 데이터를 푸시하는 기술

 

 

 이렇게 비동기 처리를 적재적소에 사용하면 더욱 성능을 향상시킬 수 있을 것 같습니다😍 아직 도입해보지 않으셨다면, 이번 기회에 도입하는 게 어떨까요? 읽어주셔서 감사합니다! 

 

 

📌 SSAFYcial 인스타그램 바로가기  @hellossafycial 

📌 SSAFYcial 홈페이지 바로가기  

댓글