Spring BootでREST APIを開発する際、認証チェックやリクエストログの出力など、複数のエンドポイントで共通する処理を一元化したい場面が多くあります。このような共通処理を効率的に実装するのがHandlerInterceptorです。

この記事では、HandlerInterceptorインターフェースの3つのメソッドの役割から、特定パスへの適用設定、複数Interceptorの実行順序制御、そして実践的なリクエストログ出力の実装例まで、順を追って解説します。

実行環境と前提条件

この記事のサンプルコードは以下の環境で動作確認しています。

項目 バージョン
Java 21
Spring Boot 3.4.1
Gradle 8.x

前提条件として、Spring Boot Webプロジェクトが作成済みであることを想定しています。spring-boot-starter-web依存関係が含まれていれば問題ありません。

1
2
3
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

HandlerInterceptorとは

HandlerInterceptorは、Spring MVCにおいてリクエストがコントローラーに到達する前後で処理を挟み込むためのインターフェースです。Servlet Filterと似た役割を持ちますが、Spring MVCのコンテキスト内で動作するため、コントローラーやハンドラーの情報にアクセスできる点が大きな特徴です。

Servlet Filterとの違い

HandlerInterceptorとServlet Filterの主な違いを以下にまとめます。

項目 HandlerInterceptor Servlet Filter
動作レイヤー Spring MVCのDispatcherServlet内 Servletコンテナレベル
ハンドラー情報へのアクセス 可能 不可
ModelAndViewへのアクセス 可能 不可
設定場所 Javaコンフィグ(WebMvcConfigurer) web.xmlまたはFilterRegistrationBean
適用範囲 Spring MVCで処理されるリクエストのみ すべてのリクエスト

ハンドラーレベルの前処理・後処理にはHandlerInterceptorを、リクエスト・レスポンスの変換やGZIP圧縮など低レベルの処理にはServlet Filterを使い分けるのが一般的です。

HandlerInterceptorの3つのメソッドの役割

HandlerInterceptorインターフェースには、以下の3つのメソッドが定義されています。それぞれリクエスト処理の異なるタイミングで呼び出されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public interface HandlerInterceptor {

    default boolean preHandle(HttpServletRequest request, 
                              HttpServletResponse response, 
                              Object handler) throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler, 
                            @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, 
                                 HttpServletResponse response, 
                                 Object handler, 
                                 @Nullable Exception ex) throws Exception {
    }
}

preHandle - コントローラー実行前の処理

preHandleメソッドは、コントローラーのハンドラーメソッドが実行される前に呼び出されます。主な用途は以下の通りです。

  • 認証・認可チェック
  • リクエストパラメータのバリデーション
  • リクエストログの記録開始
  • 処理時間計測の開始

戻り値がtrueの場合は処理が続行され、falseの場合はリクエスト処理が中断されます。中断する場合は、レスポンスを自分で設定する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public boolean preHandle(HttpServletRequest request, 
                         HttpServletResponse response, 
                         Object handler) throws Exception {
    String authHeader = request.getHeader("Authorization");
    
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"Unauthorized\"}");
        return false; // 処理を中断
    }
    
    return true; // 処理を続行
}

postHandle - コントローラー実行後・レスポンス送信前の処理

postHandleメソッドは、コントローラーのハンドラーメソッドが正常に完了した後、ビューのレンダリング前に呼び出されます。REST APIの場合はレスポンスボディが書き込まれる前に実行されます。

主な用途は以下の通りです。

  • ModelAndViewへの追加データ設定
  • レスポンスヘッダーの追加
  • ログ出力

注意点として、コントローラーで例外が発生した場合はpostHandleは呼び出されません。また、@ResponseBodyを使用するREST APIでは、レスポンスボディの内容を変更することは困難です。

1
2
3
4
5
6
7
8
@Override
public void postHandle(HttpServletRequest request, 
                       HttpServletResponse response, 
                       Object handler, 
                       ModelAndView modelAndView) throws Exception {
    response.addHeader("X-Response-Time", 
        String.valueOf(System.currentTimeMillis()));
}

afterCompletion - リクエスト完了後の処理

afterCompletionメソッドは、リクエスト処理が完全に終了した後に呼び出されます。コントローラーで例外が発生した場合でも呼び出されるため、リソースのクリーンアップに適しています。

主な用途は以下の通りです。

  • リソースのクリーンアップ
  • 処理時間の計測終了とログ出力
  • 例外情報のロギング
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public void afterCompletion(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler, 
                            Exception ex) throws Exception {
    if (ex != null) {
        log.error("リクエスト処理中にエラーが発生しました: {}", ex.getMessage());
    }
    
    Long startTime = (Long) request.getAttribute("startTime");
    if (startTime != null) {
        long duration = System.currentTimeMillis() - startTime;
        log.info("処理時間: {}ms", duration);
    }
}

3つのメソッドの実行タイミング

以下の図は、リクエストからレスポンスまでの流れにおける各メソッドの実行タイミングを示しています。

sequenceDiagram
    participant Client as クライアント
    participant DS as DispatcherServlet
    participant Int as Interceptor
    participant Ctrl as Controller

    Client->>DS: HTTPリクエスト
    DS->>Int: preHandle()
    alt preHandle が true を返す
        Int-->>DS: true
        DS->>Ctrl: ハンドラーメソッド実行
        Ctrl-->>DS: 処理結果
        DS->>Int: postHandle()
        DS->>Client: HTTPレスポンス
        DS->>Int: afterCompletion()
    else preHandle が false を返す
        Int-->>DS: false
        DS->>Client: エラーレスポンス
    end

WebMvcConfigurerによるInterceptorの登録

作成したHandlerInterceptorをSpring MVCに登録するには、WebMvcConfigurerインターフェースを実装した設定クラスを作成します。

基本的な登録方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final LoggingInterceptor loggingInterceptor;

    public WebMvcConfig(LoggingInterceptor loggingInterceptor) {
        this.loggingInterceptor = loggingInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loggingInterceptor);
    }
}

Interceptor自体は@Componentアノテーションを付与してSpring Beanとして管理することで、他のBeanをDIできます。

特定パスへのInterceptor適用設定

すべてのリクエストにInterceptorを適用するのではなく、特定のパスパターンにのみ適用したい場合は、addPathPatternsexcludePathPatternsを使用します。

パスパターンの指定

1
2
3
4
5
6
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(loggingInterceptor)
            .addPathPatterns("/api/**")           // /api/以下のすべてのパスに適用
            .excludePathPatterns("/api/health");  // ヘルスチェックは除外
}

パスパターンの記法

パスパターンにはAnt形式のワイルドカードを使用できます。

パターン 説明 マッチ例
/api/* /api/直下の1階層のみ /api/users
/api/** /api/以下のすべての階層 /api/users, /api/users/1
/api/users/* /api/users/直下の1階層 /api/users/1
/api/**/details /api/で始まりdetailsで終わる /api/users/1/details

複数のパスパターンを指定

1
2
3
4
5
6
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/admin/**", "/api/settings/**")
            .excludePathPatterns("/api/admin/login", "/api/admin/logout");
}

複数Interceptorの実行順序制御

複数のInterceptorを登録した場合、デフォルトでは登録した順序で実行されます。明示的に実行順序を制御するにはorderメソッドを使用します。

実行順序の指定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(loggingInterceptor)
            .addPathPatterns("/api/**")
            .order(1);  // 数値が小さいほど先に実行
    
    registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/public/**")
            .order(2);
    
    registry.addInterceptor(rateLimitInterceptor)
            .addPathPatterns("/api/**")
            .order(3);
}

複数Interceptorの実行フロー

複数のInterceptorが登録されている場合、preHandleは登録順(order昇順)に実行され、postHandleafterCompletionは逆順で実行されます。

sequenceDiagram
    participant DS as DispatcherServlet
    participant I1 as Interceptor1 (order=1)
    participant I2 as Interceptor2 (order=2)
    participant I3 as Interceptor3 (order=3)
    participant Ctrl as Controller

    DS->>I1: preHandle()
    I1-->>DS: true
    DS->>I2: preHandle()
    I2-->>DS: true
    DS->>I3: preHandle()
    I3-->>DS: true
    DS->>Ctrl: ハンドラー実行
    Ctrl-->>DS: 結果
    DS->>I3: postHandle()
    DS->>I2: postHandle()
    DS->>I1: postHandle()
    DS->>I3: afterCompletion()
    DS->>I2: afterCompletion()
    DS->>I1: afterCompletion()

リクエストログ出力の実装例

ここでは、実践的なリクエストログ出力Interceptorの実装例を紹介します。リクエストの開始・終了、処理時間、ステータスコードをログに出力します。

LoggingInterceptorの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Component
public class LoggingInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);
    private static final String START_TIME_ATTR = "requestStartTime";
    private static final String REQUEST_ID_ATTR = "requestId";

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) throws Exception {
        String requestId = generateRequestId();
        long startTime = System.currentTimeMillis();
        
        request.setAttribute(START_TIME_ATTR, startTime);
        request.setAttribute(REQUEST_ID_ATTR, requestId);
        
        log.info("[{}] リクエスト開始: {} {} (クライアントIP: {})",
                requestId,
                request.getMethod(),
                request.getRequestURI(),
                getClientIp(request));
        
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler, 
                           ModelAndView modelAndView) throws Exception {
        // REST APIでは通常何もしない
    }

    @Override
    public void afterCompletion(HttpServletRequest request, 
                                HttpServletResponse response, 
                                Object handler, 
                                Exception ex) throws Exception {
        Long startTime = (Long) request.getAttribute(START_TIME_ATTR);
        String requestId = (String) request.getAttribute(REQUEST_ID_ATTR);
        
        long duration = System.currentTimeMillis() - startTime;
        int status = response.getStatus();
        
        if (ex != null) {
            log.error("[{}] リクエスト完了: {} {} - ステータス: {} - 処理時間: {}ms - エラー: {}",
                    requestId,
                    request.getMethod(),
                    request.getRequestURI(),
                    status,
                    duration,
                    ex.getMessage());
        } else {
            log.info("[{}] リクエスト完了: {} {} - ステータス: {} - 処理時間: {}ms",
                    requestId,
                    request.getMethod(),
                    request.getRequestURI(),
                    status,
                    duration);
        }
    }

    private String generateRequestId() {
        return java.util.UUID.randomUUID().toString().substring(0, 8);
    }

    private String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

設定クラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final LoggingInterceptor loggingInterceptor;

    public WebMvcConfig(LoggingInterceptor loggingInterceptor) {
        this.loggingInterceptor = loggingInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loggingInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/health", "/api/metrics");
    }
}

動作確認用のコントローラー

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        // 処理のシミュレーション
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        
        return new UserResponse(id, "テストユーザー", "test@example.com");
    }
    
    public record UserResponse(Long id, String name, String email) {}
}

期待される出力結果

アプリケーションを起動し、GET /api/users/1にリクエストを送信すると、以下のようなログが出力されます。

INFO  [a1b2c3d4] リクエスト開始: GET /api/users/1 (クライアントIP: 127.0.0.1)
INFO  [a1b2c3d4] リクエスト完了: GET /api/users/1 - ステータス: 200 - 処理時間: 105ms

認証チェックInterceptorの実装例

続いて、APIキーによる認証チェックを行うInterceptorの実装例を紹介します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.io.IOException;

@Component
public class ApiKeyAuthInterceptor implements HandlerInterceptor {

    private static final String API_KEY_HEADER = "X-API-Key";
    
    @Value("${api.key}")
    private String validApiKey;

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) throws Exception {
        String apiKey = request.getHeader(API_KEY_HEADER);
        
        if (apiKey == null || apiKey.isEmpty()) {
            sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "APIキーが指定されていません");
            return false;
        }
        
        if (!validApiKey.equals(apiKey)) {
            sendErrorResponse(response, HttpStatus.FORBIDDEN, "APIキーが無効です");
            return false;
        }
        
        return true;
    }
    
    private void sendErrorResponse(HttpServletResponse response, 
                                   HttpStatus status, 
                                   String message) throws IOException {
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(
            String.format("{\"error\": \"%s\", \"status\": %d}", message, status.value())
        );
    }
}

複数Interceptorの設定例

ログ出力と認証チェックの両方を適用する設定例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final LoggingInterceptor loggingInterceptor;
    private final ApiKeyAuthInterceptor apiKeyAuthInterceptor;

    public WebMvcConfig(LoggingInterceptor loggingInterceptor, 
                        ApiKeyAuthInterceptor apiKeyAuthInterceptor) {
        this.loggingInterceptor = loggingInterceptor;
        this.apiKeyAuthInterceptor = apiKeyAuthInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // ログ出力は最初に実行(認証失敗時もログを残すため)
        registry.addInterceptor(loggingInterceptor)
                .addPathPatterns("/api/**")
                .order(1);
        
        // 認証チェックは2番目
        registry.addInterceptor(apiKeyAuthInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**", "/api/health")
                .order(2);
    }
}

Interceptor実装時の注意点

セキュリティ用途での制限

Spring公式ドキュメントでも記載されているとおり、HandlerInterceptorはセキュリティレイヤーとしての使用には向いていません。コントローラーのパスマッチングとInterceptorのパスマッチングにずれが生じる可能性があるためです。本格的な認証・認可にはSpring Securityの使用を推奨します。

非同期処理での動作

非同期リクエスト処理(@AsyncやDeferredResultなど)では、postHandleafterCompletionの呼び出しタイミングが通常とは異なります。非同期処理に対応するにはAsyncHandlerInterceptorを実装してください。

レスポンスボディの変更

postHandleでレスポンスボディを変更することは困難です。REST APIでレスポンスを加工する場合は、ResponseBodyAdviceの使用を検討してください。

まとめ

この記事では、Spring BootにおけるREST APIの共通処理を実装するためのHandlerInterceptorについて解説しました。

主なポイントを振り返ります。

  • preHandleはコントローラー実行前の処理(認証、ログ開始)に使用する
  • postHandleはコントローラー正常完了後の処理に使用する
  • afterCompletionはリクエスト完了後のクリーンアップやログ出力に使用する
  • WebMvcConfigureraddInterceptorsでInterceptorを登録する
  • addPathPatternsexcludePathPatternsで適用パスを制御する
  • orderメソッドで複数Interceptorの実行順序を制御する

HandlerInterceptorを活用することで、コントローラーのコードをシンプルに保ちながら、横断的な関心事を一元管理できます。ぜひプロジェクトに取り入れてみてください。

参考リンク