프로젝트에서 회원가입할 때와 비밀번호를 찾을 때, JavaMailSender를 사용해 메일 인증을 하고 있다.
그런데 메일 인증을 위해 인증번호 받기 API를 요청하면, 평균적으로 응답시간이 3초 이상 걸린다.
이를 개선하기 위해 서버에서 메일 전송을 비동기로 처리하고자 한다.
동기
동기는 작업을 순서대로 처리하는 것으로, 작업을 처리하며 그 작업의 완료 여부를 따진다.
또한 자바에서 한 함수를 호출하면 그 함수는 하나의 스레드가 모두 처리한다.
즉 함수 A가 실행 중에 함수 B를 호출하면, 함수 A를 처리하는 스레드가 함수 B를 처리 후 다음 코드를 실행한다.
public class Main {
public static void main(String[] args) throws InterruptedException {
func_A();
}
static void func_A() throws InterruptedException {
System.out.println("1. func_A thread name: " + Thread.currentThread().getName() + "\n");
ThreadTest.func_B();
System.out.println("\n2. func_A thread name: " + Thread.currentThread().getName());
}
}
public class ThreadTest {
static void func_B() throws InterruptedException {
for (int i = 0; i < 5; i++) {
System.out.println("func_B thread name: " + Thread.currentThread().getName());
Thread.sleep(100);
}
}
}
결과를 보면 모두 같은 main thread로 처리되었다.
func_A()는 도중 실행된 func_B()가 끝날 때까지 기다리고, func_B()가 끝난 후 다음 코드가 실행된다.
만약 func_B()가 I/O작업, 대용량 데이터 처리 또는 복잡한 계산 등을 처리하는 함수일 때, 이를 비동기로 다른 스레드를 사용해 병렬 처리한다면 응답 시간이 개선될 것이다.
비동기
비동기는 작업의 완료 여부를 따지지 않고 바로 다음 작업으로 넘어가며, 작업을 순서대로 처리하지 않을 수도 있다.
public class Main {
public static void main(String[] args) throws InterruptedException {
func_A();
}
static void func_A() throws InterruptedException {
System.out.println("1. func_A thread name: " + Thread.currentThread().getName() + "\n");
Thread newThread = new Thread(() -> {
try {
ThreadTest.func_B();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
newThread.start();
Thread.sleep(100);
System.out.println("\n2. func_A thread name: " + Thread.currentThread().getName());
}
}
실행 결과를 보면 func_A()는 func_B()를 호출 후 새로운 스레드가 func_B()를 수행했으며, 실행 결과를 기다리지 않고 다음 코드를 실행했다.
Blocking vs Non-Blocking
동기 비동기는 작업 완료의 여부를 따져 구분한다면, blocking과 non-blocking은 다음 작업을 처리하기 위해 현재 작업의 대기 여부이다.
예를 들어, I/O 작업이 있을 때 blocking 방식이라면 파일을 다 읽을 때까지 대기하고, non-blocking 방식이라면 파일을 다 읽을 때까지 대기하지 않고 다른 작업을 수행한다.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
non_blocking_func_A();
}
static void non_blocking_func_A() throws InterruptedException, ExecutionException {
System.out.println("1. func_A thread name: " + Thread.currentThread().getName() + "\n");
CompletableFuture<String> resultFuture = CompletableFuture.supplyAsync(() -> {
try {
return ThreadTest.func_B();
} catch (InterruptedException e) {
e.printStackTrace();
return "Error occurred in func_B";
}
});
String result = resultFuture.get();
System.out.println("\nfunc_B result: " + result);
System.out.println("\n2. func_A thread name: " + Thread.currentThread().getName());
}
}
위 비동기 예제의 실행 결과와 달리, 순차적으로 실행된 것 같지만 그렇지 않다.
스레드명을 보면 func_B()는 새로운 스레드로 비동기 처리 되었다.
하지만 func_B()의 반환값이 func_A()에서 필요하기 때문에, 그 결과를 기다리기 위해 main 스레드가 block 되어 있던 것이다.
정리하자면, func_B()는 비동기 처리로 새로운 스레드에서 실행되었고, func_A()는 func_B()의 반환값을 기다리기 위해 block 되어 있었기 때문에 func_A()는 비동기 함수이며 blocking 방식이다.
동기 이메일 전송
@Service
@RequiredArgsConstructor
public class MailService {
private final MailServiceSupport mailServiceSupoort;
public void checkEmail(String to) throws Exception {
//인증키
String authKey = mailServiceSupport.createKey();
//메일 내용
MimeMessage message = mailServiceSupport.createMessage(to, authKey);
//메일 전송
mailServiceSupport.sendMail(message);
}
}
@Service
@RequiredArgsConstructor
public class MailServiceSupport {
private final JavaMailSender emailSender;
public void sendMail(MimeMessage message) {
System.out.println("send Mail thread : " + Thread.currentThread().getName());
emailSender.send(message);
}
}
설명에 필요한 코드만 가져왔다.
@Test
void 비동기_메일_전송() throws Exception {
System.out.println("main thread : " + Thread.currentThread().getName());
Long start = System.currentTimeMillis();
String address = "zzangsh2946@naver.com";
String authKey = mailServiceSupport.createKey();
MimeMessage message = mailServiceSupport.createMessage(address, authKey);
mailServiceSupport.sendMail(message);
Long end = System.currentTimeMillis();
System.out.println("main thread : " + Thread.currentThread().getName());
System.out.println((end - start) + "ms");
}
위 코드를 실행했을 때,
한 개의 스레드가 모두 수행했으며 메일 전송이 끝날 때까지 blocking 되었다.
비동기 Non-Blocking 메일 전송
스프링에서는 @Async 어노테이션으로 간단하게 비동기 처리를 할 수 있다.
해당 어노테이션이 붙은 함수는 다른 스레드로 실행되며, 메소드의 실제 실행은 SpringTaskExecutor에 의해 실행된다.
비동기 처리를 위해 ThreadPoolTaskExecutor를 설정해주지 않으면, 스프링에서는 SimpleAsyncTaskExecutor가 기본적으로 사용된다. SimpleAsyncTaskExecutor는 스레드 풀을 사용하지 않고 매 작업마다 새로운 스레드를 생성하여 수행하기 때문에, 수천명의 사용자가 동시에 요청한다면 수천개의 스레드가 생성되어 문제가 생길 것이다.
- 자원 낭비 : 각 작업마다 새로운 스레드를 생성하게 되고, 동시에 많은 요청이 온다면 CPU와 메모리 자원의 사용량이 과도하게 증가할 것이다.
- 성능 저하 : 스레드는 생성과 소멸에 많은 시간과 자원이 소모된다. 각 작업마다 스레드를 생성한다면 이러한 오버헤드가 계속 발생할 것이고, 이는 성능에 영향을 끼칠 것이다.
그렇기 때문에 @EnableAsync Congifuration에서 ThreadPoolTaskExecutor 설정을 추천한다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setThreadNamePrefix("async-mail-send-");
executor.setQueueCapacity(50);
executor.setKeepAliveSeconds(30);
executor.initialize();
return executor;
}
}
AsyncConfigurer 인터페이스를 받아 구현해준다.
- setCorePoolSize : 스레드 풀의 기본 스레드 개수를 설정하고, default로 1을 가진다.
- setMaxPoolSize : 스레드 풀이 가질 수 있는 스레드의 최대 개수를 설정하며, 이보다 더 많은 스레드를 가질 수 없다.
- setQueueCapacity : 스레드 풀의 작업 대기열의 크기를 설정한다. 스레드 풀이 가득 차서 작업을 처리할 수 없을 때 대기하는 작업의 최대 개수가 된다.
- setKeepAliveSeconds : 설정한 시간동안 스레드가 대기 상태를 유지하면 스레드를 종료한다.
- setThreadNamePrefix : 생성되는 스레드 명의 접두사를 설정한다.
@Service
@RequiredArgsConstructor
public class MailServiceSupport {
private final JavaMailSender emailSender;
@Async
public void sendMail(MimeMessage message) {
emailSender.send(message);
}
}
메일 전송 함수에 @Async 어노테이션을 붙여서 메일 전송을 비동기로 처리한다.
포스트맨으로 직접 요청하고 로그를 찍어서, 어떤 스레드가 실행되었고 어떤 순서로 완료되었는지 알아보자.
@Transactional(readOnly = false)
public void checkEmail(String to) throws Exception {
String authKey = mailServiceSupport.createKey();
MimeMessage message = mailServiceSupport.createMessage(to, authKey);
log.info("before call sendMail");
mailServiceSupport.sendMail(message);
log.info("after call sendMail");
}
@Async
public void sendMail(MimeMessage message) {
log.info("start send mail");
emailSender.send(message);
log.info("end send mail");
}
직접 설정한 mail-send 스레드 풀의 스레드로 메일 전송이 실행되었으며, 순차적으로 실행되지 않았다.
테스트 코드로 실행했을 때 또한 직접 설정한 스레드 풀의 스레드로 메일 전송이 실행되었으며 순차적으로 실행되지 않았고, 실행 시간 또한 3411ms -> 211ms로 줄었다.
이는 메일 전송 작업이 비동기 처리로 백그라운드로 실행되었으며, 작업이 대기 되지 않게 Non-blocking 방식으로 처리되었다는 것을 알 수 있다.
유의할 점
ThreadPoolTaskExecutor에서 CorePoolSize가 너무 작으면 낮은 처리량을 보이고, 너무 크면 사용하지 않는 스레드가 많을 때 자원 낭비로 이어질 것이다. QueueSize 또한 작으면 적은 스레드만 대기할 수 있으며, 너무 크면 이 또한 자원 낭비어 이어질 것이다.
따라서 서버의 스펙과 트래픽에 따라 적절히 설정해야 할 것이다.
스프링 AOP로 프록시가 메서드를 호출하기 때문에 @Async를 사용하기 위해서는 public 메서드이어야 하고, 같은 클래스 내의 self-invocation을 사용하지 않아야 한다.
메일 전송의 경우 void 타입으로 반환형이 없기 때문에 비동기 non-blocking 타입으로 수행하지만,
반환 타입이 있고 이를 사용해야 한다면 Future 형식의 반환을 사용하고 비동기 blocking으로 수행될 것이다.
메일 전송에 실패한다면 이는 메일 전송을 호출한 스레드까지 전파되지 않기 때문에 기존의 Exception Handler에서 처리할 수 없다. getAsyncUncaughtExceptionHandler()를 오버라이딩하여 서버 내에서 메일 전송 실패에 대한 처리를 따로 해줄 수는 있지만, 클라이언트에는 비동기로 이미 응답되었기 때문에 메일 전송 실패 여부를 알 수 없다. 이를 위해 '메일이 오지 않았다면 재전송을 눌러주세요.'라는 문구를 넣어야 할 것이다.
결론
메일 전송을 완료하기까지 오랜 시간이 걸리는데, 이를 비동기 non-blocking으로 처리하여 응답 시간을 개선할 수 있다.
스프링 부트에서는 비동기 처리를 @Async 어노테이션으로 간편하게 사용할 수 있으며, 이를 위한 스레드 풀을 따로 설정하는 것이 좋다.
다음글
스프링 비동기 처리를 위한 ThreadPoolTaskExecutor와 테스트
스프링 비동기 처리를 위한 ThreadPoolTaskExecutor와 테스트
스프링에서 @EnableAsync 어노테이션과 비동기로 동작하기 바라는 메서드에 @Async 어노테이션을 사용해주면, 그 함수는 비동기로 작동할 수 있다. 이전 글에서 비동기 처리를 위한 TaskExecutor를 설정
prefercoding.tistory.com
'로드스타' 카테고리의 다른 글
Spring 세션과 Redis를 활용한 조회수 증가 중복 방지 (1) | 2024.05.02 |
---|---|
(2) 스프링 비동기 처리를 위한 ThreadPoolTaskExecutor와 테스트 (0) | 2024.02.26 |
스프링 + 리액트 : https, nginx, Mixed Content (0) | 2023.07.27 |