Framework/Spring

[Spring Boot]@Async로 비동기 처리하기

SHXL2 2024. 7. 18. 16:39
반응형

최근 개발 중인 앱에 비밀번호 재설정 기능을 추가하게 되었습니다.

앱에서 서버로 비밀번호 재설정 요청을 하면 비밀번호 랜덤 설정 후 메일 전송 로직을 실행하게 됩니다. 이 때, 앱에서는 메일 전송이 완료되기까지 기다릴 필요가 없기 때문에 비동기로 작동할 수 있게 처리했습니다.

 

오늘은 Spring Boot에 @Async를 이용한 비동기 처리를 알아보겠습니다.

테스트 환경
  • Spring Boot 2.7.5
  • JDK 1.8
  • Gradle

비동기 처리 없이 비밀번호 재설정을 실행했을 때의 모습입니다.

 

메일 발송까지 기다리다보니 비밀번호 재설정은 되었음에도 메일 발송 속도에 따라 3~5초 정도의 딜레이가 추가되는 것을 확인할 수 있었습니다.

*메일 발송으로 javax.mail 라이브러리를 사용하고 있고 Gmail STMP가 설정되어 있습니다.

 

이제 비동기로 앱이 동작할 수 있게 수정해보겠습니다.

 

1. 비동기 기능 활성화

Spring Boot 애플리케이션 클래스에 @EnableAsync 어노테이션을 추가합니다.

@EnableAsync
public class ApiNongsilApplication extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(ApiNongsilApplication.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(ApiNongsilApplication.class, args);
    }

}

 

 

2. 비동기 메일 발송 메소드 추가

메일 발송을 수행하는 sendMail 메소드에 @Aysnc 어노테이션을 추가합니다.

@Async
public void sendMail(EmailDto emailDto) {
    Session session = init();
    MimeMessage msg = new MimeMessage(session);

    try {
        // 발신자 이메일 설정
        msg.setSentDate(new Date());
        msg.setFrom(new InternetAddress(SENDER_EMAIL, SENDER_NAME));

        // 수신자 이메일
        InternetAddress toAddress = new InternetAddress(emailDto.getRecipient());
        msg.setRecipient(Message.RecipientType.TO, toAddress);
        // 이메일 제목
        msg.setSubject(emailDto.getEmailSubject(), "UTF-8");
        //이메일 내용
        msg.setText(emailDto.getEmailContent(), "UTF-8", "html");

    	log.info("메일 발송 중...");
        
        //메일 발송
        Transport.send(msg);
    } catch (Exception e) {
        throw new CustomException("이메일 발송에 실패했습니다.");
    }
}

 

 

3. 메일 발송 메소드 호출

resetPassword 메소드는 비밀번호를 재설정하고 변경된 랜덤 비밀번호를 메일로 발송합니다.

이제 sendMail 메소드는 비동기로 동작합니다.

@Transactional
    public String resetPassword(UserDto userDto) {
        Users userInfo = usersRepository.findByUserId(userDto.getUserId());

        if (userInfo == null) {
            throw new CustomException("계정이 존재하지 않습니다.");
        }

        String newPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 8);

        // 비밀번호 초기화
        Users updateUser = userInfo.toBuilder()
            .userPw(passwordEncoder.encode(newPassword))
            .build();
        usersRepository.save(updateUser);

        // 메일 발송
        sendPassword(updateUser, newPassword);

        return newPassword;
    }

    private void sendPassword(Users userInfo, String newPassword) {
        EmailDto emailInfo = EmailDto.builder()
            .recipient(userInfo.getUserEmail())
            .emailSubject("농실농실 비밀번호 재설정 안내")
            .emailContent("<!DOCTYPE html>\n"
                + "<html lang=\"ko\">\n"
                + "  <head>\n"
                + "    <meta charset=\"UTF-8\" />\n"
                + "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n"
                + "    <title>농실농실 비밀번호 재설정 안내</title>\n"
                + "  </head>\n"
                + "  <body>\n"
                + "    <div class=\"container\">\n"
                + "    </div>\n"
                + "  </body>\n"
                + "</html>\n")
            .build();

    	// 메일 발송
        gmailService.sendMail(emailInfo);
    }

 

 

4. 테스트

비동기로 동작하는지 확인해보겠습니다.

 

 

이전과 달리 딜레이없이 바로 진행되는 것을 확인할 수 있습니다.

 

메일은 3~5초 정도 후 받아볼 수 있었습니다. 반대로 동기 방식에서는 완료되었음과 동시에 메일을 받아볼 수 있었습니다.

이렇게 스프링에서 제공하는 비동기 처리 어노테이션을 이용해 쉽게 비동기 처리가 가능했습니다.

하지만 @Async 사용시 주의할 점이 있습니다.

 

@Async 사용시 주의점

1. 접근제어자 

비동기 메소드는 public 접근자만 사용이 가능합니다.

 

2. 호출

Self-invocation을 주의해야 합니다. Self-invocation은 클래스 내에서 자기 자신의 메서드를 호출하는 것을 합니다. @Async 어노테이션은 AOP 기반으로 동작하며, 비동기 처리를 위해 프록시 객체를 생성합니다. Self-invocation의 경우 프록시를 통해 호출되는 것이 아닌 직접 호출이 이루어지기 때문에 비동기 처리가 되지 않습니다.

같은 클래스 내부에서 직접 호출하기 위해서는 아래와 같이 Self-reference를 사용할 수 있습니다.


@Service
public class UserService {

    @Autowired
    private UserService userService;

    @Transactional
    public String resetPassword(UserDto userDto) {
        ...

        // 메일 발송
        userService.sendPassword(updateUser, newPassword);

        return newPassword;
    }

    @Async
    public void sendPassword(Users userInfo, String newPassword) {
        // 비동기 처리
    }
}

 

3. 스레드 관리

비동기 작업시 생성되는 스레드로 인해 리소스 낭비, 성능 저하 문제가 발생할 수 있습니다.

비동기 처리를 위해 사용한 @EnableAsync@Async 어노테이션은 기본적으로 SimpleAsyncTaskExecutor를 사용하게 됩니다.

SimpleAsyncTaskExceutor는 스레드풀 방식이 아니기 때문에 비동기 요청이 생길 때마다 새로운 스레드를 생성합니다. 만약 1000개의 요청이 생긴다면 스레드 역시 1000개의 스레드를 생성하게 됩니다. 때문에

ThreadPoolTaskExecutor를 설정하여 사용하는 것을  권장하고 있습니다.

 

ThreadPoolTaskExecutor를 설정하게 되면 제한된 리소스로 스레드 풀을 사용하게 됩니다.

Executor를 정의하는 방법은 다음과 같습니다.

 

1. Bean을 사용해 직접 정의하는 방법

@Async 어노테이션에 Bean 이름을 직접 지정하여 사용합니다. 여러 개의 Executor를 정의하고 각각의 Executor를 선택하여 사용할 수 있습니다.

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

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }
}

 

 

2. AsyncConfigurerSupport를 상속받는 방법

이 방법은 Executor 설정을 전역적으로 사용할 수 있습니다. AsyncConfigurerSupport를 상속받아 getAsyncExecutor()를 재정의합니다.

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig extends AsyncConfigurerSupport {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }
}

 

Executor의 옵션은 다음과 같습니다.

  • setCorePoolSize : 스레드 풀의 기본 사이즈
  • setMaxPoolSize :  스레드 풀의 최대 수
  • setQueueCapacity : 스레드 풀의 기본 사이즈보다 많은 요청 발생시 대기할 작업의 수. ex) 5개의 작업(corePoolSize)을 처리하는 동안 요청이 추가될 때, 25개까지 작업이 큐(QueueCapacity)에 대기하게 되고, 최대 10개의 스레드(MaxPoolSize)까지 생성해서 처리를 진행한다.
  • setThreadNamePrefix : 스레드 이름의 접두사

 

스레드 풀 설정은 어플리케이션 요구 사항과 환경에 따라 다르기 때문에 성능 테스트를 통해 최적의 값을 찾는 것이 중요합니다. 설정 값에 대한 정답은 저도 장담할 수 없기에 ChatGPT의 의견을 첨부합니다.. ^^

Core Pool Size: 애플리케이션이 항상 유지할 최소한의 스레드 수입니다. 보통 CPU 코어 수와 동일하거나 약간 더 많은 값을 사용합니다.
Max Pool Size: 최대 스레드 수는 예상되는 최대 동시 작업 수를 기반으로 설정합니다. 일반적으로 Core Pool Size의 2배에서 4배 정도로 설정합니다.
Queue Capacity: 스레드가 모두 사용 중일 때 대기할 작업의 최대 수입니다. 예상되는 동시 작업 수에 따라 설정합니다.

+ 피드백은 언제나 환영입니다 :)

반응형