본문 바로가기
BackEnd/Project

[RealPJ] Ch02. 비동기 프로그래밍 개념 및 실습

by 개발 Blog 2024. 8. 30.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

비동기 프로그래밍이란

  • 비동기 프로그래밍은 실시간 응답이 필요하지 않은 상황에서 주로 사용된다.
  • 예시로 Notification, Email 전송, Push 알림 등이 있다.
  • 비동기 프로그래밍을 개발자 관점에서 설명하자면, 메인 스레드가 아닌 서브 스레드에게 작업을 위임하는 행위라고 할 수 있다.

Spring에서 비동기 프로그래밍

  • Spring Framework를 사용하면 비동기 프로그래밍을 구현할 수 있다.
  • 이를 위해서는 ThreadPool을 정의할 필요가 있다.

ThreadPool 생성이 필요한 이유

  • 비동기 작업은 메인 스레드가 아닌 서브 스레드에서 작업을 진행한다.
  • Java에서는 ThreadPool을 생성하여 이러한 비동기 작업을 처리한다.

ThreadPool 생성 옵션

  • CorePoolSize: 해당 스레드 풀에서 최소한으로 유지해야 할 스레드 수를 지정한다.
  • MaxPoolSize: 최대 할당할 수 있는 스레드 수를 지정한다.
  • KeepAliveTime: 스레드가 작업을 하지 않을 경우, 유지될 시간을 설정한다.
  • unit: KeepAliveTime의 시간 단위를 지정한다.
  • WorkQueue: 대기 중인 작업을 담아놓는 큐이다.

 

순서에 대해서 설명하자면, 다음과 같다.

  • 먼저, CorePoolSize만큼의 스레드를 생성한다.
  • 작업이 CorePoolSize를 초과하면 WorkQueue에 작업을 대기시킨다.
  • WorkQueue가 가득 차면, MaxPoolSize까지 스레드를 늘려 작업을 처리한다.
  • MaxPoolSize에 도달했을 때 작업이 더 이상 처리되지 않으면 RejectedExecutionHandler가 동작하게 된다.

ThreadPoolExecutor 생성자

코드에서 사용된 ThreadPoolExecutor 생성자를 설명하겠다.

위 코드의 의미는 다음과 같다.

  • CorePoolSize = 5: 최소 5개의 스레드를 유지한다.
  • MaxPoolSize = 10: 최대 10개의 스레드를 생성할 수 있다.
  • KeepAliveTime = 3초: 5개 이상의 스레드는 3초 동안 대기 상태일 경우 제거된다.
  • WorkQueue = ArrayBlockingQueue(50): 최대 50개의 작업을 대기시킬 수 있는 큐다.

즉, 5개의 스레드는 항상 유지되며, 대기 중인 작업이 있을 경우 최대 10개까지 스레드가 생성된다. 하지만, 5개를 초과한 스레드는 3초 동안 작업이 없을 경우 제거되며, 큐에는 최대 50개의 작업이 대기할 수 있다.

 

ThreadPool 생성 시 주의해야 할 부분

  • CorePoolSize 값을 너무 크게 설정할 경우 성능 저하 등의 부작용이 발생할 수 있다.

  • IllegalArgumentException : 다음 조건 중 하나라도 성립하면 발생한다.
  • NullPointerException : workQueue가 null일 경우 발생한다.

ThreadPool  정리 

CorePoolSize

  • CorePoolSize는 스레드 풀에서 항상 유지되는 최소한의 스레드 수를 의미한다.
  • 스레드 수가 CorePoolSize보다 작을 경우 새로운 스레드를 생성하여 작업을 처리한다.
  • 스레드 수가 CorePoolSize보다 많으면, 새로 생성하지 않고 대기 큐(WorkQueue)에 작업을 추가한다.

조건 요약

  • if (Thread 수 < CorePoolSize): 새로운 스레드를 생성한다.
  • if (Thread 수 >= CorePoolSize): 대기 큐에 작업을 추가한다.

MaxPoolSize

  • MaxPoolSize는 스레드 풀에서 생성할 수 있는 최대 스레드 수를 의미한다.
  • 대기 큐(WorkQueue)가 가득 차 있고, 현재 스레드 수가 MaxPoolSize보다 작을 경우 새로운 스레드를 생성하여 작업을 처리한다.
  • 대기 큐가 가득 차 있고, 현재 스레드 수가 MaxPoolSize보다 크거나 같을 경우 요청이 거절된다.
    • if (Queue Full && Thread 수 < MaxPoolSize): 새로운 스레드를 생성한다.
    • if (Queue Full && Thread 수 >= MaxPoolSize): 요청이 거절된다.

조건 요약

  • if (Queue Full && Thread 수 < MaxPoolSize): 새로운 스레드를 생성한다.
  • if (Queue Full && Thread 수 >= MaxPoolSize): 요청이 거절된다.

실습

비동기 프로그래밍을 위해 프로젝트를 생성한다.

 

프로젝트에서 비동기 프로그래밍을 구현하기 위해 다음과 같은 패키지 구조를 만든다.

  • config: 설정 파일들을 포함.
  • controller: 컨트롤러 클래스를 포함.
  • service: 서비스 로직을 포함.

AppConfig 클래스

비동기 처리를 위한 스레드 풀을 정의한다.

package dev.be.async.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class AppConfig {

    @Bean(name = "defaultTaskExecutor")
    public ThreadPoolTaskExecutor defaultTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(200);
        executor.setMaxPoolSize(300);
        return executor;
    }

    @Bean(name = "messagingTaskExecutor", destroyMethod = "shutdown")
    public ThreadPoolTaskExecutor messagingTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(200);
        executor.setMaxPoolSize(300);
        return executor;
    }
}
  • defaultTaskExecutor와 messagingTaskExecutor 두 개의 스레드 풀을 정의하였다.

AsyncConfig 클래스

비동기 처리가 가능하도록 설정을 추가한다.

package dev.be.async.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
}
  • @EnableAsync 애노테이션을 사용하여 Spring에서 비동기 메서드를 활성화한다.

AsyncController 클래스

세 가지 테스트 케이스를 확인하기 위한 컨트롤러를 구현한다.

package dev.be.async.controller;

import dev.be.async.service.AsyncService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AsyncController {

    private final AsyncService asyncService;

    @GetMapping("/1")
    public String asyncCall_1() {
        asyncService.asyncCall_1();
        return "success";
    }

    @GetMapping("/2")
    public String asyncCall_2() {
        asyncService.asyncCall_2();
        return "success";
    }

    @GetMapping("/3")
    public String asyncCall_4() {
        asyncService.asyncCall_3();
        return "success";
    }
}

 

AsyncService 클래스

서비스 로직을 구현하며, 비동기 작업을 수행한다.

package dev.be.async.service;

import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AsyncService {
    private final EmailService emailService;

    public void asyncCall_1() {
        System.out.println("[asyncCall_1] :: " + Thread.currentThread().getName());
        emailService.sendMail();
        emailService.sendMailWithCustomThreadPool();
    }

    public void asyncCall_2() {
        System.out.println("[asyncCall_2] :: " + Thread.currentThread().getName());
        EmailService emailService = new EmailService();
        emailService.sendMail();
        emailService.sendMailWithCustomThreadPool();
    }

    public void asyncCall_3() {
        System.out.println("[asyncCall_3] :: " + Thread.currentThread().getName());
        sendMail();
    }

    @Async
    public void sendMail() {
        System.out.println("[sendMail] :: " + Thread.currentThread().getName());
    }
}

 

EmailService 클래스

비동기 메서드를 정의하고, 특정 스레드 풀을 사용하도록 설정한다.

package dev.be.async.service;

import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class EmailService {

    @Async("defaultTaskExecutor")
    public void sendMail() {
        System.out.println("[sendMail] :: " + Thread.currentThread().getName());
    }

    @Async("messagingTaskExecutor")
    public void sendMailWithCustomThreadPool() {
        System.out.println("[messagingTaskExecutor] :: " + Thread.currentThread().getName());
    }
}

 

실행 결과 및 분석

 

첫 번째 경우: 스프링이 비동기로 처리하도록 설정된 메서드에서 컨테이너에 등록된 프록시 객체를 사용하여 비동기 작업을 수행한다. 이 경우, 스레드 이름이 다르게 출력되어 비동기 처리가 정상적으로 이루어졌다.

두 번째 경우: 직접 생성된 인스턴스를 사용하여 비동기 처리가 이루어지지 않았다. 동일한 스레드에서 동작하게 되었다.

세 번째 경우: 동일한 인스턴스 내에서 직접 메서드를 호출한 경우, 비동기 처리가 되지 않았다.

 

결론

  • 비동기 프로그래밍에서 스프링 컨테이너에 등록된 빈을 사용하는 것이 중요하다.
  • 비동기 프로그래밍은 개발 환경에서 문제가 되지 않더라도, 실무에서는 장애가 발생할 수 있으므로, 스레드 네임을 통해 비동기 동작을 확인하는 것이 필요하다.