Spring Bootで非同期処理を実装する方法 - @Asyncと@EnableAsyncの使い方


APIのレスポンスが遅い原因の一つに、時間のかかる処理を同期的に実行していることがあります。たとえば、メール送信に3秒かかる処理を同期実行していると、ユーザーは登録完了まで3秒以上待たされることになりますよね。

この記事では、Spring Bootの @Async アノテーションを使って、重い処理をバックグラウンドで実行し、レスポンス速度を改善する方法を解説します。

非同期処理とは

同期処理では、メソッドを呼び出すとその処理が完了するまで次の処理に進めません。一方、非同期処理では呼び出し後すぐに制御が戻り、実際の処理はバックグラウンドで実行されます。

非同期処理が有効なのは、次のような場面です。

  • メール送信(SMTPサーバーとの通信に時間がかかる)
  • アクセスログやイベントログの記録
  • 外部APIへのデータ送信
  • レポート生成などの時間のかかる処理

逆に、即座に結果が必要な場合や、トランザクション内でのデータ更新処理には向きません。

@EnableAsyncで非同期処理を有効化する

Spring Bootで非同期処理を使うには、まず @EnableAsync アノテーションを付けます。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

メインクラスに付けるのが一般的ですが、別途 @Configuration クラスを作ってそこに付けても構いません。

この時点では、デフォルトで SimpleAsyncTaskExecutor が使われます。これは後で問題になるので、本番環境では必ずカスタマイズしましょう。

@Asyncアノテーションの基本的な使い方

メソッドに @Async を付けると、そのメソッドは非同期実行されます。

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class EmailService {

    @Async
    public void sendWelcomeEmail(String to) {
        System.out.println("[" + Thread.currentThread().getName() + "] メール送信開始: " + to);
        // メール送信処理(時間がかかる)
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("[" + Thread.currentThread().getName() + "] メール送信完了: " + to);
    }
}

コントローラーから呼び出してみます。

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    private final EmailService emailService;

    public UserController(EmailService emailService) {
        this.emailService = emailService;
    }

    @PostMapping("/register")
    public String register(@RequestBody String email) {
        System.out.println("[" + Thread.currentThread().getName() + "] 会員登録処理開始");
        emailService.sendWelcomeEmail(email);
        System.out.println("[" + Thread.currentThread().getName() + "] 会員登録処理完了(メール送信は非同期)");
        return "登録完了";
    }
}

実行すると、メール送信を待たずにすぐ「登録完了」が返ります。ログを見ると、メール送信は別スレッドで動いていることがわかりますね。

注意点として、 同じクラス内 から @Async メソッドを呼び出すと非同期になりません。Springは別のBeanから呼び出されたときだけ @Async を適用できる仕組みになっています。

CompletableFutureで非同期処理の結果を受け取る

非同期処理の結果が必要な場合は、 CompletableFuture を返すようにします。

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class ExternalApiService {

    @Async
    public CompletableFuture<String> fetchUserData(String userId) {
        System.out.println("[" + Thread.currentThread().getName() + "] APIリクエスト開始: " + userId);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        String result = "User data for " + userId;
        return CompletableFuture.completedFuture(result);
    }
}

呼び出し側では get() で結果を取得できます。

try {
    CompletableFuture<String> future = externalApiService.fetchUserData("user123");
    String data = future.get(); // ブロックして結果を待つ
    System.out.println(data);
} catch (Exception e) {
    e.printStackTrace();
}

複数の非同期処理を並行実行することもできます。

CompletableFuture<String> future1 = externalApiService.fetchUserData("user1");
CompletableFuture<String> future2 = externalApiService.fetchUserData("user2");
CompletableFuture<String> future3 = externalApiService.fetchUserData("user3");

CompletableFuture.allOf(future1, future2, future3).join();

String data1 = future1.get();
String data2 = future2.get();
String data3 = future3.get();

3つのAPIリクエストが並行実行されるので、順番に実行するより大幅に時間を短縮できます。

デフォルトのSimpleAsyncTaskExecutorの問題点

@EnableAsync だけ付けた状態では、Springは SimpleAsyncTaskExecutor を使います。これは 毎回新しいスレッドを作成・破棄 するため、リクエストが増えるとスレッド生成コストとメモリ消費が増大します。

本番環境では必ず ThreadPoolTaskExecutor を使ってスレッドプールを設定しましょう。

ThreadPoolTaskExecutorでスレッドプール設定をカスタマイズする

スレッドプールを使うと、スレッドの再利用によりリソース効率が良くなります。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

それぞれのパラメータを見ていきましょう。

  • corePoolSize: 常時起動しているスレッド数(5)
  • maxPoolSize: 最大スレッド数(10)
  • queueCapacity: キューに溜められるタスク数(25)

タスクが来たとき、まず corePoolSize のスレッドで処理します。それらが全て稼働中なら、キューに溜めます。キューも一杯になったら、maxPoolSize までスレッドを増やします。それでも処理できない場合は例外が発生します。

CPU負荷が高い処理なら corePoolSize は CPU コア数程度、I/O待ちが多い処理(メール送信、外部API呼び出し)ならもっと多くても構いません。

AsyncConfigurerで非同期処理の設定をカスタマイズする

AsyncConfigurer インターフェースを実装すると、デフォルトの Executor と例外ハンドラーを一箇所で設定できます。

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }

    @Bean(name = "mailExecutor")
    public Executor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("mail-");
        executor.initialize();
        return executor;
    }

    @Bean(name = "apiExecutor")
    public Executor apiExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("api-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            System.err.println("非同期処理でエラー発生: " + method.getName());
            throwable.printStackTrace();
        };
    }
}

複数の Executor を使い分けたい場合は、Bean 名を指定します。

@Async("mailExecutor")
public void sendEmail(String to) {
    // メール送信
}

@Async("apiExecutor")
public CompletableFuture<String> callExternalApi() {
    // API呼び出し
}

非同期処理の例外ハンドリング

void を返す非同期メソッドで例外が発生すると、呼び出し元でキャッチできません。

AsyncUncaughtExceptionHandler を使うと、バックグラウンドで発生した例外をログに記録できます(先ほどのコード例を参照)。

CompletableFuture を返す場合は、get()ExecutionException としてキャッチできます。

try {
    CompletableFuture<String> future = externalApiService.fetchUserData("user123");
    String data = future.get();
} catch (ExecutionException e) {
    System.err.println("非同期処理でエラー: " + e.getCause().getMessage());
}

トランザクションと非同期処理の注意点

非同期メソッドは 別スレッド で実行されるため、呼び出し元のトランザクションとは切り離されます。

非同期メソッド内で @Transactional を付ければ、そのメソッド専用のトランザクションが開始されます。ただし、非同期メソッドは別トランザクションで実行されるため、呼び出し元の未コミットデータは見えません。

実務では、トランザクションコミット後にイベントを発行する方法もあります。

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.context.ApplicationEventPublisher;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final ApplicationEventPublisher eventPublisher;

    public UserService(UserRepository userRepository, ApplicationEventPublisher eventPublisher) {
        this.userRepository = userRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void registerUser(String email, String name) {
        User user = new User(email, name);
        userRepository.save(user);
        eventPublisher.publishEvent(new UserRegisteredEvent(email));
    }
}

@Service
class EmailEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    public void handleUserRegistered(UserRegisteredEvent event) {
        // トランザクションコミット後に非同期でメール送信
        sendWelcomeEmail(event.getEmail());
    }
}

トランザクション境界を意識した設計については、Spring BootでREST APIの例外ハンドリングを実装する方法も参考にしてください。

実務でよくある非同期処理のパターン

パターン1: 会員登録後のウェルカムメール送信

@Service
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;

    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    @Transactional
    public void registerUser(String email, String name) {
        User user = new User(email, name);
        userRepository.save(user);
        // トランザクションコミット後にメール送信(非同期)
        emailService.sendWelcomeEmail(email);
    }
}

メール送信は時間がかかるので非同期にすることで、ユーザー登録のレスポンスが速くなります。

パターン2: 複数の外部APIを並行呼び出し

@Service
public class ProductService {

    private final ExternalApiService externalApiService;

    public ProductService(ExternalApiService externalApiService) {
        this.externalApiService = externalApiService;
    }

    public List<String> getProductDetails(List<String> productIds) throws Exception {
        List<CompletableFuture<String>> futures = productIds.stream()
            .map(externalApiService::fetchProductData)
            .toList();

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        return futures.stream()
            .map(CompletableFuture::join)
            .toList();
    }
}

3つの商品データを取得する場合、順番に呼び出すと6秒かかるところを、並行実行すれば2秒で済みます。外部APIとの通信には、RestTemplateとWebClientの使い方も参考になります。

非同期処理のテストの書き方

非同期メソッドのテストは少し工夫が必要です。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@SpringBootTest
class AsyncServiceTest {

    @Autowired
    private ExternalApiService externalApiService;

    @Test
    void testFetchUserData() throws Exception {
        CompletableFuture<String> future = externalApiService.fetchUserData("user123");
        String result = future.get();
        assertEquals("User data for user123", result);
    }

    @Test
    void testVoidAsyncMethod() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        // 非同期処理内でlatch.countDown()を呼ぶよう実装
        boolean completed = latch.await(5, TimeUnit.SECONDS);
        assertTrue(completed, "非同期処理が5秒以内に完了しませんでした");
    }
}

CompletableFuture を返すメソッドは、get() で結果を待ってアサーションできます。void を返すメソッドは CountDownLatch などで完了を待つ必要があります。テスト全般については、Spring BootでJUnitとMockitoを使ったテストの書き方が参考になります。

実装時の注意点

非同期処理が動作しない場合、次の点を確認しましょう。

  • @EnableAsync を付け忘れていないか
  • 同じクラス内からメソッドを呼び出していないか(別のBeanから呼び出す必要がある)
  • メソッドが public になっているか
  • スレッドプールが枯渇していないか(ログを確認)

スレッドプールが一杯になると TaskRejectedException が発生します。その場合は、maxPoolSizequeueCapacity を増やすか、タスクの実行時間を短縮する必要がありますね。

まとめ

Spring Bootの @Async を使えば、簡単に非同期処理を導入できます。

  • @EnableAsync@Async だけで基本的な非同期処理が可能
  • 本番環境では ThreadPoolTaskExecutor でスレッドプール設定が必須
  • AsyncUncaughtExceptionHandler で例外を適切にハンドリング
  • トランザクション境界に注意して設計する

まずはメール送信やログ記録など、小さな機能で試してから徐々に適用範囲を広げましょう。

定期的にバックグラウンド処理を実行したい場合は、@Scheduledアノテーションの使い方も検討してください。環境ごとにスレッドプール設定を変えたい場合は、Spring Boot Profilesの使い方が役立ちます。