1. 문제 상황 파악
- 프로젝트에 쿠폰을 보유하지 않은 회원 목록에 대해 이메일 전송 기능을 구현함
- 샘플 유저 데이터 20개를 삽입 후 이메일 전송 기능을 테스트했을 때 시간이 꽤 걸리는 것을 확인
⇒ 실제 소요 시간을 찍어보기로 함
1-1. 전송 시간 측정 결과
- 구현한 api는 이메일 주소 1개에 대해 이메일을 전송하는 기능을 가짐
- 따라서 api 호출만의 소요 시간으로는 여러개 이메일 전송에 걸리는 시간을 측정하기 어려울 것이라 판단
⇒ 현재 FE에서 비동기적으로 api를 호출하므로 총 시간을 브라우저의 콘솔에 출력해보기로 함
더보기
FE 코드 일부
// 이메일 전송 함수 일부
try {
await Promise.all(selectedEmails.map(async (email, index) => {
const emailTemplate = CouponEmailTemplate({ coupon: coupon }); // 이메일 템플릿 HTML 가져오기
// 실제 이메일 전송 요청
const response = await axios.post('http://localhost:8080/api/admin/coupons/email', {
couponId: coupon.id,
address: email,
template: emailTemplate // 결합된 템플릿 사용
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.status !== 200) {
throw new Error(`이메일 전송에 실패했습니다: ${email}`);
}
// 진행률 업데이트
setProgress(((index + 1) / selectedEmails.length) * 100);
await new Promise(resolve => setTimeout(resolve, 50)); // 요청 사이에 잠시 대기
}));
alert('모든 쿠폰 이메일 전송을 완료했습니다!');
onRequestClose(); // 모달 닫기
} catch (err) {
try {
await sendRefreshTokenAndStoreAccessToken();
window.location.reload();
} catch (e) {
console.error(err.message);
}
} finally {
setSending(false);
const endTime = Date.now(); // 종료 시간 기록
const duration = endTime - startTime; // 걸린 시간 계산
console.log('전송한 이메일 개수: ' + selectedEmails.length + '개');
console.log(`이메일 전송에 걸린 시간: ${duration}ms`);
}
};
- Promise.all, async / await 를 사용하여 선택된 이메일 목록에 대해 비동기적으로 api 호출하는 코드
- 시작과 끝의 시간을 기록해 걸린 시간을 계산해 콘솔 창에 출력하는 코드 추가함
- 이메일 20개 전송 시 소요 시간 측정
⇒ 20개 이메일 전송 시 18.871s (= 18871ms) 소요됨을 확인
⇒ 만약 이메일을 전송해야 하는 유저가 20명이 아닌 1만명, 10만명 … 이라면 시간이 매우 많이 소요될 것이라고 판단해 성능 개선하려고 함!
2. 문제 원인 파악
2-1. 기존 이메일 전송 관련 서비스 단 코드
@Service
@RequiredArgsConstructor
public class CouponEmailService {
private final JavaMailSender javaMailSender;
public void sendEmail(CouponEmailRequestDto dto) {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try{
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
// 메일을 받을 수신자 설정
mimeMessageHelper.setTo(dto.getAddress());
// 메일의 제목 설정
mimeMessageHelper.setSubject("FITinside 쿠폰 메일");
// 발신자 설정
mimeMessageHelper.setFrom("chm20060@gmail.com", "FITinside 관리자");
// 메일의 내용 설정
mimeMessageHelper.setText(dto.getTemplate(), true);
javaMailSender.send(mimeMessage);
} catch (Exception e) {
throw new CustomException(ErrorCode.INVALID_EMAIL_DATA);
}
}
}
⇒ 특별한 비동기 처리나 멀티스레딩 기능이 적용되지 않은 상태이므로 단일 스레드로 실행됨!
📌 단일 스레드에서 다수의 이메일 전송을 순차적으로 처리할 때의 문제점
- 단일 스레드에서는 한 가지 작업이 실행 ~ 완료 될 때 까지 다른 작업을 할 수 없음
- 따라서 첫 번째 이메일 전송이 완료된 후에야 두 번째 이메일 전송이 시작됨
- 즉, 대략적으로 (한 개의 이메일을 보내는 시간) * (이메일 목록의 수) 만큼의 소요시간이 걸릴 것임!
3. 해결 방법 및 결과 검증
3-1. 비동기 처리
- 특정 작업을 별도의 스레드에서 수행하게 함
⇒ 애플리케이션의 반응성을 높일 수 있음
⇒ 메인 스레드의 블로킹 피할 수 있음 - 소요 시간이 긴 작업 등을 메인 애플리케이션의 흐름과 분리할 수 있음!
3-1-1. 스프링에서의 비동기 처리
1. @Async 어노테이션
- 비동기 메서드 정의 시 사용
- 해당 어노테이션이 붙은 메서드는 호출 시 새로운 스레드에서 실행됨
@Async
public void asyncMethod() {
// 생략 ...
}
2. Executor
- 비동기 작업을 처리할 스레드 풀 설정
- 스프링은 기본적으로 SimpleAsyncTaskExecutor 사용함
@Configuration
@EnableAsync // 기본적으로 SimpleAsyncTaskExecutor 사용됨
public class AsyncConfig {
}
- ThreadPoolTaskExecutor
- 기존의 SimpleAsyncTaskExecutor는 스레드 풀을 사용하지 않으므로 매번 새로운 스레드 생성함
⇒ 성능이 저하될 수 있음! - ThreadPoolTaskExecutor 사용해 스레드 풀 설정 가능
⇒ 비동기 메서드 호출 시 스레드 풀에서 스레드를 가져와 작업 처리하게 되어 성능, 자원 관리 측면에서 더 효율적일 수 있음!
- 기존의 SimpleAsyncTaskExecutor는 스레드 풀을 사용하지 않으므로 매번 새로운 스레드 생성함
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor taskExecutor() { // ThreadPoolTaskExecutor 사용
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 최소 스레드 수
executor.setMaxPoolSize(5); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기열 크기
executor.initialize();
return executor;
}
}
3-2. 프로젝트에 적용
3-2-1. AsyncConfig 생성
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 최소 스레드 수
executor.setMaxPoolSize(10); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기열 크기
executor.initialize();
return executor;
}
}
- 확장 가능성을 염두에 두고 더 효율적인 관리를 위해 ThreadPoolTaskExecutor 설정함
3-2-2. @Async 어노테이션 추가
@Service
@RequiredArgsConstructor
public class CouponEmailService {
private final JavaMailSender javaMailSender;
@Async // 비동기 작업을 위한 어노테이션 추가
public void sendEmail(CouponEmailRequestDto dto) {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try{
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
// 메일을 받을 수신자 설정
mimeMessageHelper.setTo(dto.getAddress());
// 메일의 제목 설정
mimeMessageHelper.setSubject("FITinside 쿠폰 메일");
// 발신자 설정
mimeMessageHelper.setFrom("chm20060@gmail.com", "FITinside 관리자");
// 메일의 내용 설정
mimeMessageHelper.setText(dto.getTemplate(), true);
javaMailSender.send(mimeMessage);
} catch (Exception e) {
throw new CustomException(ErrorCode.INVALID_EMAIL_DATA);
}
}
}
- @Async 어노테이션을 추가해 비동기 작업이 가능하게 함
3-3. 성능 개선 결과 확인
- 기존과 동일하게 20개의 이메일 주소에 이메일을 전송해 비교 진행
⇒ 20개 이메일 전송에 571ms 소요됨을 확인
⇒ 기존 18871ms 대비, 약 96.97% 의 성능 개선을 확인 (더 많은 이메일로 비교한다면 더 큰 성능 개선을 이룰 것으로 예상!)