NCBT V2 Java 메일 비동기로 처리하기
NCBT 프로젝트에서 자바 메일로 메일 전송하는 로직이 있는데 너무 느렸다. 체감 3초~5초정도 화면이 멈춰있다보니 사용자 입장에서 에러가 발생한 것처럼 느껴질 정도였다. 개발 당시에는 급하게 처리하느라 이 부분을 넘겼는데 이번 V2 버전에서는 최적화를 해보고자 한다. 방법을 찾아보니 메일을 전송하는 부분만 비동기로 처리하고 화면을 넘기면 해결이 될 것 같아서 개선해 보겠다.
ExcutorService vs @Async
우선 Java 에서 비동기를 구현하는 방법을 알아보았다. 비동기와 멀티 스레드는 다른 개념이지만, Java 에서는 비동기를 구현하기 위해 멀티 스레드를 사용한다고 하니 스레드를 다룰 수 있는 기능이 있는지 찾아보았다.
항목 | @Async | ExcutorService |
---|---|---|
환경 | Spring AOP 기반 (Spring 컨텍스트 필요) | 순수 Java |
간편성 | 매우 간단 (@Async 만 붙이면 됨) | 직접 스레드풀을 생성, submit 필요 |
결과 반환 | Future, CompletableFuture, void 지원 | Future 등 명시적 반환 |
예외 처리 | 로깅 또는 핸들러 설정 필요 | Future.get() 으로 직접 예외 확인 |
스레드풀 설정 | 전역 또는 AsyncConfigurer 로 설정 | 직접 정의 (유연한 제어 가능) |
사용 대상 | Spring Bean 내부에서만 사용 | 어디서든 사용 가능 (범용적) |
ExcutorService
Excutor 는 작업 실행만 가능한 기본 인터페이스이고, 이걸 상속한 것이 ExcutorService 이다. 단순히 스레드 하나의 작업만 실행할 때는 Excutor 를 써도 되지만, 여러 작업을 병렬 처리, 처리 결과까지 필요하다면 ExcutorService 를 사용해야 한다.
ExecutorService executor = Executors.newFixedThreadPool(3);
Future<String> future = executor.submit(() -> {
// 비동기 작업 실행
return "결과";
});
String result = future.get(); // 결과 확인
executor.shutdown(); // 스레드풀 종료
@Async
메서드에 @Async 만 붙이면 자동으로 스레드풀에서 실행된다. 사실 @Async 도 내부적으로는 Excutor 또는 ExcutorService 를 주입받아 사용되기에 커스텀한 ExcutorService 를 주입해서 사용할 수도 있다.
그리고 Spring은 @Async 가 붙은 메서드를 호출할 때, 그 메서드를 프록시 객체가 대신 실행하도록 만든다 (Spring AOP 프록시를 통해 동작). 때문에 “프록시 객체 내부에서 자기 자신을 호출하면 AOP가 적용되지 않는다”는 제한이 생긴다. 다시 말하면 같은 클래스 안에서 @Async 가 붙은 메서드를 자기 자신이 직접 호출하면 비동기로 실행되지 않고 동기로 실행된다는 뜻이다. 그러므로 @Async 를 사용해 비동기로 구현하려면 해당 메서드를 다른 클래스로 분리해야 한다.
@Service
public class MyService {
@Async
public void asyncMethod() {
System.out.println("비동기 실행: " + Thread.currentThread().getName());
}
public void callAsyncFromInside() {
asyncMethod(); // ❌ 이건 비동기 아님! (동기로 실행됨)
}
}
@Service
public class AsyncWorker {
@Async
public void asyncMethod() {
System.out.println("비동기 실행: " + Thread.currentThread().getName());
}
}
@Service
public class MyService {
private final AsyncWorker asyncWorker;
public MyService(AsyncWorker asyncWorker) {
this.asyncWorker = asyncWorker;
}
public void callAsyncProperly() {
asyncWorker.asyncMethod(); // ✅ 프록시 통해 호출 → 비동기로 실행됨
}
}
Java 메일 비동기로 구현
위 두 가지 방법 중에서 스프링 환경에서 간편하게 사용 가능한 @Async 를 선택해서 구현해보도록 하겠다.
💡 코드
@Service
public class MailServiceImpl implements MailService {
private final JavaMailSender mailSender;
private final UserService userService;
public MailServiceImpl(JavaMailSender mailSender, UserService userService) {
this.mailSender = mailSender;
this.userService = userService;
}
@Async
public void sendComplaintsToAdmin(PracticeComplaintsDTO practiceComplaintsDTO) {
String title = "[NCBT] 문제 오류가 접수되었습니다.";
String content =
"제목 : " + practiceComplaintsDTO.getTitle()
+ "내용 : " + practiceComplaintsDTO.getContent();
List<User> adminList = userService.findAdmin();
adminList.stream()
.map(user -> createEmail(user.getEmail(), title, content))
.forEach(mailSender::send);
}
// 발송할 이메일 정보
public SimpleMailMessage createEmail(String toEmail, String title, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail);
message.setSubject(title);
message.setText(content);
return message;
}
}
// 비동기 기능을 전체 애플리케이션에 활성화시키려면 메인 클래스 혹은 설정 클래스에 @EnableAsync 가 붙어야 한다.
@SpringBootApplication(scanBasePackages = {"kr.kh.backend.common", "kr.kh.backend.v1", "kr.kh.backend.v2"})
@MapperScan(basePackages = {"kr.kh.backend.v1.mapper", "kr.kh.backend.v2.mapper"})
@EntityScan(basePackages = "kr.kh.backend.v2.entity")
@EnableAsync
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
💡 JMeter 테스트 결과 : 11.6ms -> 2.6ms (약 77% 개선)
💡 예외 처리
@Async 메서드의 예외 처리는 일반적인 try-catch 블록으로 잡히지 않는다. 왜냐면 다른 스레드에서 실행되기 때문에 잡은 예외가 메인 스레드에 전달되지 않기 때문이다. 예외의 로깅이 필요한 경우에는 try-catch 를 사용하면 되고, 예외 전달이 필요한 경우 Future 또는 CompletableFuture 를 사용한다.
Future.get() : 블로킹 방식이라 비동기의 장점이 손실될 수 있다.
CompletableFuture() : 논블로킹 방식으로 예외 처리 가능하다.
다만, void 반환형의 @Async 메서드는 Future 를 받을 수 없다. 이럴 땐 Spring 이 제공하는 전역 예외처리 핸들러를 등록해야 한다. 메일 발송 기능은 예외가 발생하더라도 사용자의 화면에 보일 필요가 없기 때문에 별도로 핸들러는 등록하지 않았고 로깅만 추가했다.