과정을 즐기자

Spring에서 멀티 쓰레드 비동기 프로그래밍 해보기 본문

Spring

Spring에서 멀티 쓰레드 비동기 프로그래밍 해보기

320Hwany 2023. 12. 8. 15:11

Spring은 기본적으로 멀티 쓰레드, 동기 방식으로 작동합니다. 하지만 성능 향상을 위해서 비동기 방식으로 작동을 하도록

할 수 있습니다. 이번 글에서는 싱글 쓰레드/멀티 쓰레드, 동기/비동기에 대한 설명과 함께 Spring에서 멀티 쓰레드

비동기 프로그래밍을 해보겠습니다.

Java의 쓰레드 모델

먼저 자바의 쓰레드에 대해 알아보겠습니다.

쓰레드의 종류에는 여러가지가 있지만 주요 쓰레드로는 OS 쓰레드, 유저 쓰레드가 있습니다.

 

OS 쓰레드는 OS 커널 레벨에서 생성되고 관리되는 쓰레드입니다. 즉, CPU에서 실제로 실행되는 단위를 말하며

CPU의 스케줄링의 단위가 됩니다. 사용자의 코드, 커널 코드 모두 OS 쓰레드에서 실행된다고 볼 수 있습니다.

 

유저 쓰레드는 쓰레드의 개념을 프로그래밍 레벨에서 추상화 한 것입니다.

자바에는 Thread 클래스가 있는데 이것이 유저 쓰레드입니다.

void test() {
    Runnable runnable = 
   		  () -> System.out.println("Thread: " + Thread.currentThread().getName());
    Thread thread = new Thread(runnable);

    thread.start();
}

즉, 프로그램에서 OS 커널이 제공하는 서비스를 이용하고 싶을 때 시스템 콜을 통해 실행하는데 쓰레드와 관련된

시스템 콜은 이미 추상화된 Thread 클래스를 통해 사용할 수 있습니다.

 

OS 쓰레드와 유저 쓰레드가 연결되는 방식에 따라 One-to-One, Many-to-One, Many-to-Many 모델이 있는데

자바는 One-to-One 모델로 OS 쓰레드와 유저 쓰레드가 1대1로 연결됩니다.

싱글 쓰레드/멀티 쓰레드

Java의 쓰레드 모델에 대해 알아보았으니 이제 쓰레드에 대해 더 자세히 알아보겠습니다.

싱글 쓰레드는 하나의 프로세스에 하나의 쓰레드만 있는 방식이고 멀티 쓰레드는 하나의 프로세스의 여러 개의 쓰레드가 

있습니다. 멀티 쓰레드를 사용하면 하나의 프로세스에서 Code, Data, Heap 부분을 공유하고 Stack 부분만 

각각의 쓰레드가 따로 가집니다.

 

CPU 코어가 4개인 예시를 살펴보겠습니다. 만약 쓰레드 모델이 One-to-One 이라면 프로그래밍 레벨에서 만든 유저 쓰레드와

OS 쓰레드가 1대1로 연결되고 이 OS 쓰레드에 CPU를 할당합니다.

싱글 쓰레드라면 CPU 코어 하나만 활용할 수 있기 때문에 비효율적이라고 할 수 있습니다.

하지만 멀티 쓰레드라면 코어가 많아지면 이를 더 잘 활용할 수 있는 것입니다.

하지만 멀티 쓰레드는 프로세스의 주소 중 Stack 부분을 제외한 Code, Data, Heap 을 공유하기 때문에

동시성 문제에 대해서 주의해야 합니다.

동기/비동기, Blocking/Non-Blocking

동기와 비동기에 대한 많은 정의가 있어 혼란 스럽지만 기본적인 정의는 요청한 작업을 순차적으로 처리했는가로 구분됩니다.

동기 방식은 요청한 작업을 순차적으로 처리하고 비동기 방식은 순차적으로 처리되지 않을 수도 있습니다.

 

 

다른 말로 표현해보면 동기는 요청한 작업을 응답 받아야 다음 요청을 진행할 수 있다는 것입니다.

동기/비동기 방식과 함께 같이 다니는 개념으로 Blocking/Non-Blocking이 있습니다. 일반적으로 동기는 Blocking이고

비동기는 Non-Blocking 인 경우가 많아 같은 개념이라고 생각할 수 있지만 정의 자체는 다릅니다.

 

동기/비동기는 요청한 작업의 순차적 처리 여부에 따라 결정되고 Blocking/Non-Blocking은 요청한 작업의 제어권이 넘어갔는지

여부에 따라 결정됩니다. 해당 쓰레드가 제어권을 넘겨주면 Blocking, 자신이 계속 제어권을 가지고 있으면 Non-Blocking 입니다.

 

동기 방식으로 작동하는 경우 순차적으로 처리하기 위해서 쓰레드의 제어권이 넘어가고 Blocking 되는 경우가 많은 것이지

동기 방식이면서 Non-Blocking도 충분히 가능합니다.

마찬가지로 비동기 방식으로 작동하는 경우 어떤 쓰레드가 해당 작업을 진행하다가 I/O 작업이 필요할 때 성능을 위해 제어권을

넘기지 않고 Non-Blocking 방식으로 자신의 코드를 계속 실행하는 경우가 많은 것이지 제어권을 넘기고 Blocking 되는 경우도

가능합니다.

Spring에서 기본적으로 사용하는 방식

위에서 싱글 쓰레드/멀티 쓰레드, 동기/비동기 개념에 대해서 정리해봤는데 Spring은 기본적으로 어떤 방식을 사용할까요?

Spring은 기본적으로 멀티 쓰레드, 동기 방식을 사용합니다. Thread per Request 모델로 하나의 요청을 처리하기 위해

하나의 쓰레드를 사용합니다.

 

예를들어 보겠습니다. 3개의 요청이 동시에 와서 3개의 쓰레드 A, B, C 에 각각 할당되었다고 하겠습니다.

A 쓰레드가 어떤 요청을 처리할 때 I/O 작업이 필요해지면 A 쓰레드는 Blocking 됩니다.

다른 요청이 들어오더라도 A 쓰레드는 해당 요청에 대한 처리가 아직 끝나지 않았기 때문입니다.

 

하지만 Thread per Request 모델은 하나의 요청마다 하나의 쓰레드를 사용하기 때문에 매번 쓰레드를 생성하면

비용이 커지기 때문에 Spring boot를 사용할 경우 기본적으로 쓰레드 풀에 200개의 쓰레드를 만들어 놓습니다.

 

Platform Thread는 유저 쓰레드와 같은 개념이라고 생각하시면 됩니다.

비동기 방식으로 할 수 있는 방법

멀티 쓰레드/동기 방식으로 많은 양의 트래픽을 처리할 수 있고 실제로 대부분 이러한 방식으로 처리합니다.

여기서 애플리케이션의 처리량을 더 늘리려면 쓰레드 개수를 늘려야 하지만 그럴경우 컨텍스트 스위칭 비용이 증가하며

메모리 사용량도 증가하게 됩니다. 그렇기 때문에 쓰레드의 개수를 늘리는 방식은 한계가 있습니다.

 

쓰레드 개수를 늘리지 않고 처리량을 늘리기 위해서는 비동기 방식을 생각해볼 수 있습니다.

Spring에서 비동기 방식으로 처리하는 방법에는 Spring webflux, 가상 쓰레드, @Async 사용이 있습니다.

Spring Webflux

Spring webflux는 기존 방식에는 처리량 한계가 있어 비동기/Non-Blocking 방식의 애플리케이션 개발을 지원하는

모듈입니다. 하지만 이러한 Reactive 프로그래밍 방식은 코드를 작성하고 이해하는 비용을 높였습니다.

또한 기존의 자바 프로그래밍은 쓰레드를 기반으로 하기 때문에 기존 라이브러리들 모두 Reactive 방식에 맞게 새롭게

작성해야 하는 문제가 있습니다.

그렇기 때문에 기본적인 Spring 방식을 사용하되 트래픽이 많은 서비스만 부분적으로 채택해서 사용하는 것 같습니다.

가상 쓰레드

2023년 9월에 자바 21이 나오면서 가상 쓰레드 개념이 도입되었습니다.

가상 쓰레드는 OS 쓰레드와 1대1로 연결되지 않고 JVM 자체적으로 스케줄링하여 Carrier Thread를 통해 연결합니다.

기존의 방식은 요청을 처리하는 쓰레드가 Blocking이 발생하면 OS 쓰레드도 Blocking 되었지만 가상 쓰레드 방식은

가상 쓰레드에 Blocking 이 발생하면 내부 스케줄링을 통해 OS 쓰레드와 연결되는 Carrier 쓰레드는 다른 가상 쓰레드의 작업을

처리하면 됩니다. 결과적으로 OS 쓰레드는 Blocking 되지 않게되는 것입니다.

 

 

이렇게 가상 쓰레드 방식이 도입되어 처리량 한계가 있는 기존 방식의 단점을 보완하고 작성/이해하기 어려운 Spring webflux의

단점을 보완할 수 있는 방법이 될 수 있을 것 같습니다.

@Async 

@Async 어노테이션은 Spring에서 제공하여 비동기 방식으로 프로그래밍 할 수 있게 도와줍니다.

가장 쉽게 비동기 방식을 사용할 수 있는 방법으로 하나의 예시를 들어 설명해보겠습니다.

 

어떠한 주문 10개가 있고 각각의 주문에 할인을 적용해야 하며 할인을 적용하는데 1초가 걸린다고 해봅시다.

그렇다면 주문 10개 처리 요청이 들어오면 순차적으로 처리하면 10초가 걸립니다. 하지만 해당 작업은 서로 독립적이기

때문에 순차적으로 할 필요가 없습니다. 따라서 여러 쓰레드가 비동기 방식으로 동작하도록 하여 시간을 단축 시켜보겠습니다.

Order

실행에 약 1초의 시간이 걸리는 할인 후 가격을 계산하는 calculatePrice 메소드를 생성합니다.

@Slf4j
public record Order(
        String orderName,
        int price
) {

    public int calculatePrice(final int discountPrice) {
        log.info("OrderName={}, Start Calculate Price", orderName);
        try {
            Thread.sleep(1000);
            log.info("OrderName={}, End Calculate Price", orderName);

            return price - discountPrice;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

AsyncConfig

비동기 방식을 이용하기 위한 기본 설정을 해줍니다. 

@EnableAsync // 비동기 기능 활성화
@Configuration
public class AsyncConfig {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10); // 기본 실행 대기 쓰레드 수
        executor.setMaxPoolSize(10); // 동시 동작 최대 쓰레드 수
        executor.setQueueCapacity(500); // 초과 요청시 최대 수용 가능한 큐의 크기
        executor.setThreadNamePrefix("Async Task");
        executor.initialize();

        return executor;
    }
}

AsyncTask

@Async
public void calculateDiscountPrice(final Order order) {
    int price = order.calculatePrice(1000);
    log.info("{}의 할인 후 가격={}", order.orderName(), price);
}

OrderAsyncService

@Slf4j
@Service
public class OrderAsyncService {

    private final AsyncTask asyncTask;

    public OrderAsyncService(final AsyncTask asyncTask) {
        this.asyncTask = asyncTask;
    }

    public void calculatePrice(final List<Order> orders) {
        for (Order order : orders) {
            asyncTask.calculateDiscountPriceV2(order);
        }
    }
}

이러한 방식으로 각각의 주문에 대한 계산을 독립적으로 처리할 수 있습니다.

정리

이번 글에서는 Spring에서 멀티 쓰레드 비동기 프로그래밍을 하는 방식에 대해 알아보았습니다.

Spring은 기본적으로 멀티 쓰레드, 동기 방식인데 처리량을 높이기 위해 쓰레드를 무작정 늘릴 수 없습니다.

이를 해결하기 위한 방식으로 Spring webflux, 가상 쓰레드, @Async 어노테이션 사용에 대해 알아보았고

가장 간단한 @Async 어노테이션은 사용 방법에 대해서도 알아보았습니다.

 

Spring webflux는 개발 난이도가 높아 사용 비율이 낮았었는데 자바 21 에 나온 가상 쓰레드 개념의 등장으로 조금 더 편하게

Non-Blocking 방식을 활용할 수 있을 것 같습니다. 또한 처리량을 높이기 위해 Non-Blocking 방식이 확실히 장점이 있지만

멀티 쓰레드의 경우에는 동시성 문제에 대해 주의해야 합니다.

 

앞으로의 프레임워크 개발의 방향성이 CPU 코어 수보다 조금 더 많은 쓰레드를 이용한 Non-Blocking 방식을 추구하는 것이라는

글을 본 적이 있는데 자바의 가상 쓰레드가 그러한 방식으로 발전할 것 같아 궁금하기도 합니다. 다음에는 자바 가상 쓰레드에 대해

정리한 글을 작성해보겠습니다.

참고한 자료

 

👩‍💻 완벽히 이해하는 동기/비동기 & 블로킹/논블로킹

동기/비동기 & 블로킹/논블록킹 프로그래밍에서 웹 서버 혹은 입출력(I/O)을 다루다 보면 동기/비동기 & 블로킹/논블로킹 이러한 용어들을 접해본 경험이 한번 쯤은 있을 것이다. 대부분 사람들은

inpa.tistory.com

 

Virtual Thread란 무엇일까? (1)

Software Developer, I love code.

findstar.pe.kr