NCBT V2 Java 메일 비동기로 처리하기

NCBT 프로젝트에서 자바 메일로 메일 전송하는 로직이 있는데 너무 느렸다. 체감 3초~5초정도 화면이 멈춰있다보니 사용자 입장에서 에러가 발생한 것처럼 느껴질 정도였다. 개발 당시에는 급하게 처리하느라 이 부분을 넘겼는데 이번 V2 버전에서는 최적화를 해보고자 한다. 방법을 찾아보니 메일을 전송하는 부분만 비동기로 처리하고 화면을 넘기면 해결이 될 것 같아서 개선해 보겠다.


ExcutorService vs @Async

우선 Java 에서 비동기를 구현하는 방법을 알아보았다. 비동기와 멀티 스레드는 다른 개념이지만, Java 에서는 비동기를 구현하기 위해 멀티 스레드를 사용한다고 하니 스레드를 다룰 수 있는 기능이 있는지 찾아보았다.

항목@AsyncExcutorService
환경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 이 제공하는 전역 예외처리 핸들러를 등록해야 한다. 메일 발송 기능은 예외가 발생하더라도 사용자의 화면에 보일 필요가 없기 때문에 별도로 핸들러는 등록하지 않았고 로깅만 추가했다.