이번 포스팅에서는 `StudyWithMe`에서 `@Async`을 통한 비동기 작업과
비동기 작업에 관한 `Thread Pool` 및 `CompletableFuture`에 관해 알아본 내용을 정리하려고 한다.
요약
- @Async 적용 이유
- CompletableFuture 사용 이유
- 비동기 스레드 풀
1. @Async 적용 이유
처음 이메일 발송 로직을 아래와 같이 `@Async`를 적용하지 않고 동기적으로 작동하도록 작업했다.
EmailService
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private static final String authCodeEmailTitle = "스터디 윗 미 인증코드";
private static final String retrieveEmailTitle = "스터디 윗 미 가입 이메일 안내";
private final JavaMailSender mailSender;
...
public void sendAuthCodeEmail(String toEmail, String text, EmailSendType type) {
String htmlContent = createAuthCodeHTMLEmail(text, type);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(toEmail);
helper.setSubject(authCodeEmailTitle);
helper.setText(htmlContent, true);
mailSender.send(message);
} catch (MessagingException | MailException e) {
log.error("MailService.sendAuthCodeEmail exception occur toEmail: {}, " +
"title: {}, text: {}", toEmail, authCodeEmailTitle, text);
}
}
...
}
위의 로직에서 Postman을 통해 이메일에 관한 응답을 동기적으로 기다리면 `응답시간`이 얼마나 나올까?
결과
약, `4.58s`가 나온다.
이는 너무 긴 `응답시간`이며
저렇게 이메일 전송 로직이 끝날 때까지 동기적으로 스레드가 마냥 기다리고 있다면,
트래픽이 많은 상황에서 큰 지연으로 이어질 수 있다.
또한, 이메일 전송과 같은 경우는 `CPU 작업` 보다 `IO 작업`이 중심이다.
즉, `CPU`가 연산하는 시간보다 `네트워크 I/O`가 더 길다는 의미이다.
특히, 이메일을 전송할 때 애플리케이션은 `SMTP` 서버와 네트워크를 통해 통신한다.
이 과정은 네트워크 대기 시간이 주요 병목이 된다.
정리하자면, 이메일 같은 `IO 작업`의 중심인 경우에는 스레드가 대기 상태에 머무는 경우가 많으므로,
비동기로 처리해 네트워크 대기 시간 동안 다른 작업을 수행하여 좀 더 스레드를 효율적으로 사용하는게 좋다.
그럼 `Spring Boot`는 기본적으로 동기적으로 작동하는데, 어떻게 비동기를 사용하는데?
비동기 설정
`@EnableAsync` 어노테이션을 사용하면 손쉽게 `@Async`를 사용할 수 있다.
(필자는 Application에 해당 어노테이션을 달아주었지만, 따로 Config를 사용해도 된다.)
@EnableAsync
public class SwmBackendApplication {
public static void main(String[] args) {
SpringApplication.run(SwmBackendApplication.class, args);
}
}
그럼 다시 `@Async` 코드를 사용해서 `응답시간`을 체크해보자.
변경된 EmailService
@Async("asyncExecutor")
public void sendAuthCodeEmail(String toEmail, String text, EmailSendType type) {
String htmlContent = createAuthCodeHTMLEmail(text, type);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(toEmail);
helper.setSubject(authCodeEmailTitle);
helper.setText(htmlContent, true);
mailSender.send(message);
} catch (MessagingException | MailException e) {
log.error("MailService.sendAuthCodeEmail exception occur toEmail: {}, " +
"title: {}, text: {}", toEmail, authCodeEmailTitle, text);
}
}
`@Async`를 달아주어 비동기로 처리할 수 있도록 했다.(`asyncExecutor`에 관해서는 후술한다.)
결과
내부적으로 비동기로 처리하기 때문에 `응답시간`은 `10ms` 밖에 되지 않는다.
2. CompletableFuture 사용 이유
기존에 `CompletableFuture`에 관해 비동기 상황의 예외처리를 위해 사용한다고는 알고만 있었고, 사용한 경험은 없었다.
근데 무슨 바람이 들어 갑자기 사용했냐?
`StudyWithMe`에서 이메일 전송 로직은 다음과 같은 순서로 진행된다.
- 유저가 수신 받고 싶은 이메일을 `StudyWithMe`에 요청
- `StudyWithMe` 내의 `EmailService`가 인증코드를 전달
- 추후, 인증코드 검증을 위해 `Redis`에 인증코드 저장
위의 순서로 진행되며 작성한 코드는 아래와 같다.
UserCommandService
public void sendAuthCode(String loginId, EmailSendType type) {
String authCode = RandomUtils.generateRandomCode();
String redisPrefix = getRedisPrefix(type);
emailService.sendAuthCodeEmail(loginId, authCode, type);
redisService.setValueWithExpiration(
redisPrefix + loginId,
authCode,
ExpirationTime.EMAIL.getValue()
);
}
코드를 보면 알 수 있겠지만, 이메일로 인증코드를 발송한 뒤 `Redis`에 인증코드를 저장한다.
하지만, 모종의 이유(네트워크, 서버)로 이메일 전송에 실패한다면?
`Redis`에는 불필요한 내용이 저장되게 된다.
"에이.. 뭐 그 정도로?"로 생각할 수 있겠지만, 그래도 불편하고 그 작은 차이가 혹시 모르지 않는가
그래서, 이메일 전송에 성공했을 경우 `Redis`에 해당 내용을 저장하기로 했다.
아, 그리고 이메일 전송 실패 시 재전송 로직은 사용하지 않았다. 이유는 아래와 같다.
- Application은 성공적으로 처리했는데 순간 네트워크 이슈로 사용자에게 이메일이 늦게 전송될 수 있다.
- 그런 경우 사용자가 왜 이메일이 안오지? 하고 여러 번 누르면 여러 개의 재전송 로직이 반복될 수 있다.
그럼 비동기에서 이메일 전송의 성공과 실패를 어떻게 알고 처리할건데?
그때, 사용할 수 있는 것이 `CompletableFuture`이다.
CompletableFuture란?
`CompletableFuture`은 `Java5`의 `Future`가 갖는 한계점인
- 외부에서 완료시킬 수 없음
- get의 타임아웃 설정으로만 완료 가능
- 블로킹 코드(get)를 통해서만 이후의 결과 처리 가능
- 여러 Future 조합 불가
- 회원 정보를 가져오고, 알림을 발송하는 작업
- 여러 작업의 조합 및 예외처리 불가능
들을 해결해서 `Java8`에 도입된 클래스이다.
`CompletableFuture`은 기존의 `Future`를 기반으로, 외부에서 완료시킬 수 있어서 지어진 이름이다.
`CompletableFuture`의 등장으로 아래 예시 등이 가능해졌다.
- Future에서 불가능했던 "몇 초 이내에 응답이 안 오면 기본값을 반환"이 가능
- 외부에서 작업을 완료시킬 수 있음
- 콜백 등록 및 Future 조합이 가능
다양한 `CompletableFuture`의 사용이 궁금하다면 블로그를 참고하도록 하자.
즉, `CompletableFuture`를 사용하면 비동기 작업 처리와 관련된 추가 작업 처리가 가능하다.
`CompletableFuture`의 사용
EmailService
@Async("asyncExecutor")
public CompletableFuture<Boolean> sendAuthCodeEmail(String toEmail, String text, EmailSendType type) {
String htmlContent = createAuthCodeHTMLEmail(text, type);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(toEmail);
helper.setSubject(authCodeEmailTitle);
helper.setText(htmlContent, true);
mailSender.send(message);
return CompletableFuture.completedFuture(true);
} catch (MessagingException | MailException e) {
log.error("MailService.sendAuthCodeEmail exception occur toEmail: {}, " +
"title: {}, text: {}", toEmail, authCodeEmailTitle, text);
return CompletableFuture.completedFuture(false);
}
}
위 코드에서 볼 수 있는 것처럼
- 이메일을 전송에 성공했다면
- `CompletableFuture.completedFuture(true)`를 반환한다.
- 이메일 전송에 실패했다면
- `CompletableFuture.completedFuture(false)`를 반환한다.
그리고 아래와 같은 작업 콜백 처리를 통해 `Redis`에 조건부로 인증 코드를 저장한다.
UserCommandService
public void sendAuthCode(String loginId, EmailSendType type) {
String authCode = RandomUtils.generateRandomCode();
String redisPrefix = getRedisPrefix(type);
emailService.sendAuthCodeEmail(loginId, authCode, type).thenAccept(success -> {
if (success) {
redisService.setValueWithExpiration(
redisPrefix + loginId,
authCode,
ExpirationTime.EMAIL.getValue()
);
}
});
}
`success`가 true일 경우에만 `Redis`에 인증 코드를 저장한다.
`thenAccept`는 작업 콜백으로
- 반환 값을 받아 처리하고 값을 반환하지 않는다.
- 함수형 인터페이스 Consumer를 파라미터로 받는다.
지금까지, 우리는 이메일 전송 로직에 관해 `@Async` 적용과 `CompletableFuture`를 사용해보았다.
위 두 단계를 거쳐 비동기를 적용하고, 적절한 예외처리 가능했다.
하지만, 아직 `@Async` 사용에 관해 고려해봐야 할 것이 남아있다.
3. 비동기 스레드 풀
무엇을 더 고려해야 할까?
그건 바로 비동기 스레드 풀이다.
본격적으로 비동기 스레드 풀에 대해 설명하기에 앞서,
위에서 언급한 것처럼 `@Async`를 잘 사용하고 `@EnableAsync`를 잘 사용했다면,
"비동기 스레드는 어떻게 할당될까?"
`SimpleAsyncTaskExecutor`를 사용하여 할당된다.
`SimpleAsyncTaskExecutor`의 특징
- 스레드 풀을 사용하지 않음: 요청마다 새 스레드를 생성
- 스레드 재사용 없음: 한 번 생성된 스레드는 작업이 끝나면 제거
- 리소스 사용량이 늘어날 수 있음: 요청이 많아질 경우, 스레드 생성 비용이 크기에 성능 문제 발생
- 동작 방식
- `@Async`로 선언된 메서드 호출 시, `SimpleAsyncTaskExecutor`는 새로운 스레드를 생성해 작업 처리
- Tomcat 스레드 풀은 전혀 관여 X
- `@Async`로 선언된 메서드 호출 시, `SimpleAsyncTaskExecutor`는 새로운 스레드를 생성해 작업 처리
즉, 한번의 요청에 하나의 스레드가 생긴다.(스레드 생성은 비용이 큼)
`SimpleAsyncTaskExecutor`의 단점
- 스레드 관리의 부재: 스레드 수를 제한하거나 재사용하지 않기에, 동시 요청이 많아지면 시스템이 과부화
- 성능 저하: 스레드 생성과 제거의 비용이 크다
위의 내용처럼 `SimpleAsyncTaskExecutor`는 요청마다 새롭게 스레드를 생성하므로, 굉장히 비용이 크다.
따라서, `spring.io`에서는 `thread pool` 방식의 `TaskExecutor` 사용을 권장한다.
또한, `TaskExecutor`를 설정할 때 및 기본 스레드 풀 크기는 `Integer.MAX_VALUE`로 되어있기 때문에
사실상 무제한에 가까운 스레드의 생성이 가능하다.
즉, 적절한 스레드 개수의 `TaskExecutor` 설정을 해주어야 한다.
`StudyWithMe`의 비동기 스레드 풀 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean(name = "asyncExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 기본적으로 유지할 스레드 수
executor.setMaxPoolSize(30); // 최대 스레드 수
executor.setQueueCapacity(100); // 작업 대기 큐 크기
executor.setThreadNamePrefix("AsyncExecutor-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public Executor getAsyncExecutor() {
return asyncExecutor(); // @Async에서 사용할 기본 TaskExecutor
}
}
위 코드의 설정을 좀 더 자세히 살펴보자.
- CorePoolSize
- 최소 스레드 수로, 이 수의 스레드는 계속 유지가 된다.
- 만약 스레드 풀이 비어 있다면, 새로운 작업이 들어올 때 이 최소 스레드 수가 유지된다.
- MaxPoolSize
- 최대 스레드 수로, 풀에 할당할 수 있는 스레드의 최대 값이다.
- 이 값을 너무 크게 설정하면, 많은 스레드가 생성되어 시스템 자원을 과도하게 소모한다.
- 반대로, 너무 작게 설정하면 대기 중인 작업이 늘어난다.
- QueueCapacity
- 대기 큐의 크기이다.
- 대기 큐에 작업이 쌓이면 스레드를 더 생성할 수 있으므로, 대기 큐의 크기도 적절히 설정해야 한다.
기본적으로 Tomcat의 스레드 풀과 이름부터 매우 흡사하다.
하나 알면 좋은 점은 `Tomcat Thread Pool`과 위에서 새롭게 생성한 `Async Thread Pool`은 별개의 `Thread Pool`이다.
그렇기 때문에, 별도로 가져다가 사용하기에 비동기 작업과 동기 작업의 독립적인 활용이 가능하다.
여기서 좀 더 디테일하게 들어가면 `CorePoolSize`를 설정하는 경우에 있어 좀 더 고민해봐야 하는 부분이 있다.
바로 CPU의 코어 수 및 비동기 작업의 작업 방식에 맞춰 스레드 풀 개수를 설정해야 한다는 것이다.
CPU-bound 작업(수학적 계산)
- CPU-bound 작업은 스레드 수를 코어 수와 비슷하게 설정하는 것이 좋다.
- CPU 작업이 많이 일어나기 때문에 과도한 스레드는 성능 저하를 초래한다.
- 4코어인 경우 4(코어 수와 비슷하게)
I/O-bound 작업(네트워크, 파일 I/O)
- 대기 시간이 길지 CPU의 작업은 실제로 굉장히 짧게 일어난다.
- 따라서, 상대적으로 적은 자원을 소모해 스레드 수를 늘려도 성능에 미치는 영향이 크지 않다.
- 4코어인 경우 10 ~ 20까지 괜찮다.(대기 시간이 많으므로 더 많은 스레드가 필요)
`StudyWithMe`에서는 현재 `@Async`를 사용하는 로직이 이메일 전송 로직 뿐이다.
이메일 전송은 앞서 말한 것처럼 네트워크 대기 시간이 훨씬 길다.
따라서, `비동기 스레드 풀`에 관해서도 `CorePoolSize`를 코어 수보다 좀 더 높게 잡아도 괜찮다.
(그렇다고 만약 너무 많은 스레드를 생성하면 메모리를 많이 차지하게 되며, `Context-Switching`이 자주 일어난다.)
Tomcat의 기본 min-spare도 10인 이유도 보통 유저의 요청은 GET 요청이므로,
DB I/O 작업이 훨씬 더 많이 일어난다.
따라서, 코어 수보다 높은 10을 기본값으로 설정하지 않았나 싶다.
생성한 `Async Thread Pool` 적용
@Async("asyncExecutor")
public CompletableFuture<Boolean> sendAuthCodeEmail(String toEmail, String text, EmailSendType type) {
String htmlContent = createAuthCodeHTMLEmail(text, type);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(toEmail);
helper.setSubject(authCodeEmailTitle);
helper.setText(htmlContent, true);
mailSender.send(message);
return CompletableFuture.completedFuture(true);
} catch (MessagingException | MailException e) {
log.error("MailService.sendAuthCodeEmail exception occur toEmail: {}, " +
"title: {}, text: {}", toEmail, authCodeEmailTitle, text);
return CompletableFuture.completedFuture(false);
}
}
만약, 추후에 비동기 로직에서 많은 수학적 계산이 필요하다면 새로 스레드 풀을 만들던가,
아니면 위에서 설정한 `Async Thread Pool`를 적절하게 조절하는 작업이 필요해보인다.
정리
이번 포스팅에서는 `StudyWithMe`에서의
- @Async 적용 이유
- CompletableFuture 사용 이유
- 비동기 스레드 풀
에 관해 알아보았다.
사실 이 포스팅에서는 안담겨져 있지만(바로 포스팅 예정),
이번 기회에 Tomcat의 스레드 풀에 관해서 많이 알아보게 되었다.
만약, 자신의 서비스에 `Async` 작업이 필요해서 관련 설정을 할 경우 관련 기초 지식을 얻어갈 수 있다고 생각한다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 2 (0) | 2025.01.24 |
---|---|
[StudyWithMe] 유저의 사업자 검수 요청을 처리하며 - 1 (0) | 2025.01.23 |
[StudyWithMe] 스터디 윗 미 dev 무중단 배포 (0) | 2024.12.24 |
[StudyWithMe] 영속성 컨텍스트와 관련된 퀴즈 풀어보실 분? (0) | 2024.12.23 |
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |