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でスレッドプール設定をカスタマイズする

Spring Boot 3.2+のVirtual Threads(Project Loom)と@Async

Spring Boot 3.2 以降では Java 21 の Virtual Threads(仮想スレッド / Project Loom) が正式サポートされ、application.yml で1行有効化するだけで Tomcat のリクエストスレッドや @Async の実行スレッドを仮想スレッドに切り替えられます。

spring:
  threads:
    virtual:
      enabled: true

この設定を有効にすると、@Async メソッドはデフォルトで仮想スレッド上で実行されるようになります(明示的に Executor を指定しない場合)。仮想スレッドは OS スレッドより遥かに軽量で、I/O 待ちが多いタスク(メール送信、外部 API 呼び出し、DB クエリ)では ThreadPoolTaskExecutor を細かくチューニングするより、仮想スレッドに任せた方がシンプルで性能も出やすいです。

Virtual Threads と ThreadPoolTaskExecutor の使い分け

  • I/O bound(メール送信・外部 API・DB アクセス): Virtual Threads が有利。スレッドプールサイズの上限を気にせずに大量の同時タスクを捌けます。
  • CPU bound(画像処理・暗号化・複雑な計算): 従来通り ThreadPoolTaskExecutor で CPU コア数程度に絞った方が良い。仮想スレッドでも実行は OS スレッドに乗るため、CPU が足りなければ詰まります。

仮想スレッド有効時でも、特定の @Async メソッドだけは従来のプールを使いたい場合は Bean 名を指定すれば共存可能です。

@Async("cpuBoundExecutor")
public CompletableFuture<byte[]> resizeImage(byte[] source) {
    // CPU 集約処理は専用プールで
}

注意点

  • 仮想スレッド上で synchronized ブロックや JNI 呼び出しを使うと、キャリアスレッドが pinning(固定)され仮想スレッドの利点が失われます。Java 21 時点では ReentrantLock への置き換えを検討してください(Java 24+ で改善予定)。
  • ThreadLocal は引き続き動作しますが、仮想スレッドは数万単位で生成されるため、ThreadLocal に重いオブジェクトを保持するとメモリ消費が膨らみます。
  • Java 21 以上 + Spring Boot 3.2 以上が前提です。それ以前のバージョンでは従来通り 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で非同期処理の設定をカスタマイズする

本番運用で必須の3つの追加設定

概念だけで止めず、本番でハマりがちな次の3点を押さえておきましょう。

1. RejectedExecutionHandlerの選択

スレッドプールもキューも一杯のときの挙動を RejectedExecutionHandler で制御できます。デフォルトは AbortPolicyTaskRejectedException を投げる)です。

import java.util.concurrent.ThreadPoolExecutor;

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
  • AbortPolicy(デフォルト): 例外を投げる。タスクをロストしたくない場合は呼び出し元でリトライ処理が必要。
  • CallerRunsPolicy: 呼び出し元スレッドでタスクを実行する。HTTPリクエストスレッドが詰まるが、バックプレッシャーがかかり過負荷を防げる。Web アプリでは一番無難。
  • DiscardPolicy: タスクを黙って捨てる。ログ系で落としても問題ない場合に。
  • DiscardOldestPolicy: キューの最古タスクを捨てて新規タスクを入れる。最新データを優先したい場合に。

2. Graceful Shutdown(処理中タスクの取りこぼし防止)

デプロイ時にアプリが停止すると、実行中の非同期タスクが途中で打ち切られてしまいます。次の2行で、停止時に実行中タスクの完了を待たせられます。

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

Kubernetes など本番デプロイの全体的な対応はSpring Bootのグレースフルシャットダウンとゼロダウンタイムデプロイも合わせて参照してください。

3. TaskDecoratorでMDC・SecurityContextを伝播する

@Async は別スレッドで実行されるため、MDC(ログのトレースID)や SecurityContextHolder(認証情報)は そのままでは引き継がれませんTaskDecorator を使って明示的にコピーします。

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;

import java.util.Map;

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}
executor.setTaskDecorator(new MdcTaskDecorator());

これで非同期処理のログにも呼び出し元と同じ traceId が出力されるようになり、本番障害時の追跡が一気に楽になります。MDC の活用方法についてはSpring BootのGlobalExceptionHandlerを本番運用向けに実装するも参考になります。

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の使い方が役立ちます。