REST APIの開発・運用において、リクエストとレスポンスの内容をログに記録することは、デバッグや障害調査、監査証跡の確保に不可欠です。Spring Bootでは、標準で提供されるCommonsRequestLoggingFilterを使った簡易的なリクエストログから、ContentCachingRequestWrapperとContentCachingResponseWrapperを活用したカスタムFilterによる詳細なログ出力まで、様々なアプローチが可能です。
本記事では、リクエストログ出力の実装方法を段階的に解説します。基本的な設定から始め、リクエスト・レスポンスボディの完全な記録、機密情報のマスキング処理、そしてパフォーマンスへの影響と対策まで、本番環境で使える実践的なテクニックを紹介します。
実行環境と前提条件#
この記事のサンプルコードは以下の環境で動作確認しています。
| 項目 |
バージョン |
| 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'
}
|
リクエストログ出力方法の比較#
Spring Bootでリクエストログを出力する方法は複数あります。それぞれの特徴を理解し、要件に応じて適切な方法を選択することが重要です。
| 方法 |
リクエストボディ |
レスポンスボディ |
実装難易度 |
用途 |
| CommonsRequestLoggingFilter |
取得可能(制限あり) |
取得不可 |
低 |
開発環境でのデバッグ |
| カスタムFilter + ContentCaching |
完全に取得可能 |
完全に取得可能 |
中 |
本番環境での監査ログ |
| HandlerInterceptor |
取得困難 |
取得困難 |
中 |
処理時間計測やメタ情報ログ |
| AOP |
メソッド引数として取得 |
戻り値として取得 |
中 |
ビジネスロジック層のログ |
本記事では、Filter層でのリクエストログ出力に焦点を当てて解説します。
CommonsRequestLoggingFilterによる簡易ログ出力#
CommonsRequestLoggingFilterとは#
CommonsRequestLoggingFilterは、Spring Frameworkが提供するリクエストログ出力用のFilterです。AbstractRequestLoggingFilterを継承しており、リクエストURI、クエリ文字列、クライアント情報、ヘッダー、ペイロードをログに出力できます。
設定が非常に簡単で、開発環境でのデバッグ用途に適しています。
基本的な設定#
CommonsRequestLoggingFilterを有効にするには、Beanとして登録し、ログレベルをDEBUGに設定します。
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
|
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
@Configuration
public class RequestLoggingConfig {
@Bean
public CommonsRequestLoggingFilter requestLoggingFilter() {
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
// クエリ文字列を含める
filter.setIncludeQueryString(true);
// クライアント情報(リモートアドレス、セッションID)を含める
filter.setIncludeClientInfo(true);
// リクエストヘッダーを含める
filter.setIncludeHeaders(true);
// リクエストボディを含める
filter.setIncludePayload(true);
// ペイロードの最大長(バイト)
filter.setMaxPayloadLength(10000);
// ログメッセージのプレフィックス
filter.setBeforeMessagePrefix("REQUEST DATA: [");
filter.setAfterMessagePrefix("REQUEST DATA: [");
return filter;
}
}
|
ログレベルの設定#
CommonsRequestLoggingFilterはDEBUGレベルでログを出力するため、application.ymlでログレベルを設定する必要があります。
1
2
3
|
logging:
level:
org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG
|
出力されるログの例#
上記の設定で、以下のようなログが出力されます。
1
|
2026-01-04 17:00:00.123 DEBUG [http-nio-8080-exec-1] o.s.w.f.CommonsRequestLoggingFilter : REQUEST DATA: [POST /api/users, client=192.168.1.100, session=null, headers=[content-type:"application/json", accept:"*/*", host:"localhost:8080"], payload={"name":"John","email":"john@example.com"}]
|
特定のヘッダーを除外する#
認証トークンなどの機密情報を含むヘッダーをログから除外するには、setHeaderPredicateメソッドを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
@Bean
public CommonsRequestLoggingFilter requestLoggingFilter() {
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
filter.setIncludeQueryString(true);
filter.setIncludeClientInfo(true);
filter.setIncludeHeaders(true);
filter.setIncludePayload(true);
filter.setMaxPayloadLength(10000);
// Authorizationヘッダーを除外
filter.setHeaderPredicate(headerName ->
!headerName.equalsIgnoreCase("Authorization"));
return filter;
}
|
CommonsRequestLoggingFilterの制限事項#
CommonsRequestLoggingFilterには以下の制限があります。
- レスポンスボディを取得できない: リクエストログのみで、レスポンス内容は記録されません
- リクエストボディの取得タイミング:
afterRequestメソッドでのみペイロードが取得されるため、処理前のログにはボディが含まれません
- ストリーム消費の問題: 内部で
ContentCachingRequestWrapperを使用しますが、アプリケーションがボディを読み取らない場合はキャッシュされません
これらの制限を克服するには、カスタムFilterを実装する必要があります。
カスタムFilterによるリクエスト・レスポンスログ出力#
ContentCachingRequestWrapperとContentCachingResponseWrapper#
Spring Frameworkは、リクエスト・レスポンスのボディをキャッシュするためのラッパークラスを提供しています。
- ContentCachingRequestWrapper: リクエストボディを読み取る際にキャッシュし、後から
getContentAsByteArray()で取得可能にします
- ContentCachingResponseWrapper: レスポンスボディを内部バッファに書き込み、後から
getContentAsByteArray()で取得可能にします
これらを組み合わせることで、リクエスト・レスポンス両方のボディをログに出力できます。
カスタムLoggingFilterの実装#
以下は、リクエストとレスポンスの詳細をログ出力するカスタムFilterの実装例です。
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
package com.example.demo.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.UUID;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestResponseLoggingFilter.class);
// ログ出力するボディの最大長
private static final int MAX_PAYLOAD_LENGTH = 10000;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// リクエストIDを生成してMDCに設定(ログの追跡用)
String requestId = UUID.randomUUID().toString().substring(0, 8);
// ラッパーでリクエスト・レスポンスを包む
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper(request, MAX_PAYLOAD_LENGTH);
ContentCachingResponseWrapper wrappedResponse =
new ContentCachingResponseWrapper(response);
long startTime = System.currentTimeMillis();
try {
// リクエスト情報をログ出力(ボディはこの時点では空)
logRequest(requestId, wrappedRequest);
// 後続のフィルターチェーンを実行
filterChain.doFilter(wrappedRequest, wrappedResponse);
} finally {
long duration = System.currentTimeMillis() - startTime;
// レスポンス情報をログ出力
logResponse(requestId, wrappedRequest, wrappedResponse, duration);
// レスポンスボディをクライアントに送信
wrappedResponse.copyBodyToResponse();
}
}
private void logRequest(String requestId, ContentCachingRequestWrapper request) {
StringBuilder message = new StringBuilder();
message.append("\n========== REQUEST ==========\n");
message.append("Request ID: ").append(requestId).append("\n");
message.append("Method: ").append(request.getMethod()).append("\n");
message.append("URI: ").append(request.getRequestURI()).append("\n");
String queryString = request.getQueryString();
if (queryString != null) {
message.append("Query: ").append(queryString).append("\n");
}
message.append("Remote Address: ").append(request.getRemoteAddr()).append("\n");
message.append("Headers:\n");
Collections.list(request.getHeaderNames()).forEach(headerName ->
message.append(" ").append(headerName).append(": ")
.append(request.getHeader(headerName)).append("\n")
);
log.info(message.toString());
}
private void logResponse(String requestId,
ContentCachingRequestWrapper request,
ContentCachingResponseWrapper response,
long duration) {
StringBuilder message = new StringBuilder();
message.append("\n========== RESPONSE ==========\n");
message.append("Request ID: ").append(requestId).append("\n");
message.append("Status: ").append(response.getStatus()).append("\n");
message.append("Duration: ").append(duration).append("ms\n");
// リクエストボディを取得(処理後に取得可能)
byte[] requestBody = request.getContentAsByteArray();
if (requestBody.length > 0) {
String body = new String(requestBody, StandardCharsets.UTF_8);
message.append("Request Body: ").append(truncate(body)).append("\n");
}
// レスポンスボディを取得
byte[] responseBody = response.getContentAsByteArray();
if (responseBody.length > 0) {
String body = new String(responseBody, StandardCharsets.UTF_8);
message.append("Response Body: ").append(truncate(body)).append("\n");
}
log.info(message.toString());
}
private String truncate(String content) {
if (content.length() <= MAX_PAYLOAD_LENGTH) {
return content;
}
return content.substring(0, MAX_PAYLOAD_LENGTH) + "... [truncated]";
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// 静的リソースやヘルスチェックはログ対象外
String path = request.getRequestURI();
return path.startsWith("/actuator") ||
path.startsWith("/static") ||
path.endsWith(".css") ||
path.endsWith(".js") ||
path.endsWith(".ico");
}
}
|
重要なポイントの解説#
上記の実装で重要なポイントを解説します。
OncePerRequestFilterの継承
OncePerRequestFilterを継承することで、リクエストごとに1回だけフィルターが実行されることが保証されます。リダイレクトやフォワード時の重複実行を防ぎます。
@Orderアノテーション
@Order(Ordered.HIGHEST_PRECEDENCE)を指定することで、このフィルターが最初に実行されます。これにより、他のフィルターやコントローラーで発生した例外も含めて、完全なリクエスト・レスポンスを記録できます。
copyBodyToResponseの呼び出し
ContentCachingResponseWrapperはレスポンスボディを内部バッファに保持するため、最後にcopyBodyToResponse()を呼び出してクライアントにボディを送信する必要があります。これを忘れると、クライアントは空のレスポンスを受け取ります。
shouldNotFilterのオーバーライド
静的リソースやヘルスチェックエンドポイントなど、ログ出力が不要なパスを除外することで、ログの可読性とパフォーマンスを向上させます。
出力されるログの例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
2026-01-04 17:00:00.123 INFO [http-nio-8080-exec-1] c.e.d.f.RequestResponseLoggingFilter :
========== REQUEST ==========
Request ID: a1b2c3d4
Method: POST
URI: /api/users
Remote Address: 192.168.1.100
Headers:
content-type: application/json
accept: */*
host: localhost:8080
2026-01-04 17:00:00.234 INFO [http-nio-8080-exec-1] c.e.d.f.RequestResponseLoggingFilter :
========== RESPONSE ==========
Request ID: a1b2c3d4
Status: 201
Duration: 111ms
Request Body: {"name":"John","email":"john@example.com"}
Response Body: {"id":1,"name":"John","email":"john@example.com","createdAt":"2026-01-04T17:00:00"}
|
機密情報のマスキング処理#
本番環境でリクエストログを出力する際、パスワードやクレジットカード番号などの機密情報をそのまま記録することはセキュリティ上のリスクとなります。機密情報をマスキングする仕組みを実装しましょう。
マスキング対象の定義#
まず、マスキング対象となるフィールド名とパターンを定義します。
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
|
package com.example.demo.filter;
import java.util.List;
import java.util.regex.Pattern;
public class MaskingPatterns {
// マスキング対象のJSONフィールド名
public static final List<String> SENSITIVE_FIELDS = List.of(
"password",
"newPassword",
"confirmPassword",
"creditCardNumber",
"cardNumber",
"cvv",
"secretKey",
"apiKey",
"accessToken",
"refreshToken"
);
// マスキング対象のヘッダー名
public static final List<String> SENSITIVE_HEADERS = List.of(
"Authorization",
"X-API-Key",
"Cookie",
"Set-Cookie"
);
// クレジットカード番号のパターン(16桁の数字)
public static final Pattern CREDIT_CARD_PATTERN =
Pattern.compile("\\b\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4}\\b");
// メールアドレスのパターン
public static final Pattern EMAIL_PATTERN =
Pattern.compile("\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b");
}
|
マスキング処理の実装#
JSONボディとヘッダーのマスキング処理を実装します。
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
84
85
86
87
88
|
package com.example.demo.filter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Component;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
@Component
public class LogMaskingService {
private static final String MASK = "********";
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* JSONボディ内の機密フィールドをマスキングする
*/
public String maskJsonBody(String jsonBody) {
if (jsonBody == null || jsonBody.isBlank()) {
return jsonBody;
}
try {
JsonNode rootNode = objectMapper.readTree(jsonBody);
maskSensitiveFields(rootNode);
return objectMapper.writeValueAsString(rootNode);
} catch (JsonProcessingException e) {
// JSONパースに失敗した場合は、正規表現でマスキング
return maskWithRegex(jsonBody);
}
}
private void maskSensitiveFields(JsonNode node) {
if (node.isObject()) {
ObjectNode objectNode = (ObjectNode) node;
Iterator<Map.Entry<String, JsonNode>> fields = objectNode.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
String fieldName = field.getKey();
if (isSensitiveField(fieldName)) {
objectNode.put(fieldName, MASK);
} else {
maskSensitiveFields(field.getValue());
}
}
} else if (node.isArray()) {
for (JsonNode element : node) {
maskSensitiveFields(element);
}
}
}
private boolean isSensitiveField(String fieldName) {
return MaskingPatterns.SENSITIVE_FIELDS.stream()
.anyMatch(sensitive -> sensitive.equalsIgnoreCase(fieldName));
}
private String maskWithRegex(String content) {
// クレジットカード番号をマスキング
Matcher cardMatcher = MaskingPatterns.CREDIT_CARD_PATTERN.matcher(content);
content = cardMatcher.replaceAll("****-****-****-****");
// JSONフィールドのパターンマッチでマスキング
for (String field : MaskingPatterns.SENSITIVE_FIELDS) {
String pattern = "\"" + field + "\"\\s*:\\s*\"[^\"]*\"";
content = content.replaceAll(pattern, "\"" + field + "\":\"" + MASK + "\"");
}
return content;
}
/**
* ヘッダー値をマスキングする
*/
public String maskHeader(String headerName, String headerValue) {
if (MaskingPatterns.SENSITIVE_HEADERS.stream()
.anyMatch(sensitive -> sensitive.equalsIgnoreCase(headerName))) {
return MASK;
}
return headerValue;
}
}
|
マスキング対応のLoggingFilterへの統合#
先ほど実装したRequestResponseLoggingFilterにマスキング処理を統合します。
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
|
package com.example.demo.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.UUID;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecureRequestResponseLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(SecureRequestResponseLoggingFilter.class);
private static final int MAX_PAYLOAD_LENGTH = 10000;
private final LogMaskingService maskingService;
public SecureRequestResponseLoggingFilter(LogMaskingService maskingService) {
this.maskingService = maskingService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestId = UUID.randomUUID().toString().substring(0, 8);
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper(request, MAX_PAYLOAD_LENGTH);
ContentCachingResponseWrapper wrappedResponse =
new ContentCachingResponseWrapper(response);
long startTime = System.currentTimeMillis();
try {
logRequest(requestId, wrappedRequest);
filterChain.doFilter(wrappedRequest, wrappedResponse);
} finally {
long duration = System.currentTimeMillis() - startTime;
logResponse(requestId, wrappedRequest, wrappedResponse, duration);
wrappedResponse.copyBodyToResponse();
}
}
private void logRequest(String requestId, ContentCachingRequestWrapper request) {
StringBuilder message = new StringBuilder();
message.append("\n========== REQUEST ==========\n");
message.append("Request ID: ").append(requestId).append("\n");
message.append("Method: ").append(request.getMethod()).append("\n");
message.append("URI: ").append(request.getRequestURI()).append("\n");
String queryString = request.getQueryString();
if (queryString != null) {
message.append("Query: ").append(queryString).append("\n");
}
message.append("Remote Address: ").append(request.getRemoteAddr()).append("\n");
message.append("Headers:\n");
// ヘッダーをマスキングしてログ出力
Collections.list(request.getHeaderNames()).forEach(headerName -> {
String headerValue = request.getHeader(headerName);
String maskedValue = maskingService.maskHeader(headerName, headerValue);
message.append(" ").append(headerName).append(": ")
.append(maskedValue).append("\n");
});
log.info(message.toString());
}
private void logResponse(String requestId,
ContentCachingRequestWrapper request,
ContentCachingResponseWrapper response,
long duration) {
StringBuilder message = new StringBuilder();
message.append("\n========== RESPONSE ==========\n");
message.append("Request ID: ").append(requestId).append("\n");
message.append("Status: ").append(response.getStatus()).append("\n");
message.append("Duration: ").append(duration).append("ms\n");
// リクエストボディをマスキングしてログ出力
byte[] requestBody = request.getContentAsByteArray();
if (requestBody.length > 0) {
String body = new String(requestBody, StandardCharsets.UTF_8);
String maskedBody = maskingService.maskJsonBody(body);
message.append("Request Body: ").append(truncate(maskedBody)).append("\n");
}
// レスポンスボディをマスキングしてログ出力
byte[] responseBody = response.getContentAsByteArray();
if (responseBody.length > 0) {
String body = new String(responseBody, StandardCharsets.UTF_8);
String maskedBody = maskingService.maskJsonBody(body);
message.append("Response Body: ").append(truncate(maskedBody)).append("\n");
}
log.info(message.toString());
}
private String truncate(String content) {
if (content.length() <= MAX_PAYLOAD_LENGTH) {
return content;
}
return content.substring(0, MAX_PAYLOAD_LENGTH) + "... [truncated]";
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/actuator") ||
path.startsWith("/static") ||
path.endsWith(".css") ||
path.endsWith(".js") ||
path.endsWith(".ico");
}
}
|
マスキング後のログ出力例#
1
2
3
4
5
6
7
|
2026-01-04 17:00:00.234 INFO [http-nio-8080-exec-1] c.e.d.f.SecureRequestResponseLoggingFilter :
========== RESPONSE ==========
Request ID: a1b2c3d4
Status: 201
Duration: 111ms
Request Body: {"username":"john","password":"********","email":"john@example.com"}
Response Body: {"id":1,"username":"john","accessToken":"********"}
|
構造化ログ形式での出力#
監視ツールやログ分析基盤との連携を考慮すると、JSON形式の構造化ログが有効です。以下は、ログをJSON形式で出力するバージョンの実装です。
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
package com.example.demo.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StructuredLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(StructuredLoggingFilter.class);
private static final int MAX_PAYLOAD_LENGTH = 10000;
private static final ObjectMapper objectMapper = new ObjectMapper();
private final LogMaskingService maskingService;
public StructuredLoggingFilter(LogMaskingService maskingService) {
this.maskingService = maskingService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestId = UUID.randomUUID().toString();
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper(request, MAX_PAYLOAD_LENGTH);
ContentCachingResponseWrapper wrappedResponse =
new ContentCachingResponseWrapper(response);
long startTime = System.currentTimeMillis();
Instant timestamp = Instant.now();
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
} finally {
long duration = System.currentTimeMillis() - startTime;
logStructured(requestId, timestamp, wrappedRequest, wrappedResponse, duration);
wrappedResponse.copyBodyToResponse();
}
}
private void logStructured(String requestId,
Instant timestamp,
ContentCachingRequestWrapper request,
ContentCachingResponseWrapper response,
long duration) {
try {
Map<String, Object> logEntry = new LinkedHashMap<>();
logEntry.put("timestamp", timestamp.toString());
logEntry.put("requestId", requestId);
logEntry.put("type", "HTTP_ACCESS");
// リクエスト情報
Map<String, Object> requestData = new LinkedHashMap<>();
requestData.put("method", request.getMethod());
requestData.put("uri", request.getRequestURI());
requestData.put("queryString", request.getQueryString());
requestData.put("remoteAddress", request.getRemoteAddr());
// ヘッダー(マスキング済み)
Map<String, String> headers = new LinkedHashMap<>();
Collections.list(request.getHeaderNames()).forEach(name ->
headers.put(name, maskingService.maskHeader(name, request.getHeader(name)))
);
requestData.put("headers", headers);
// リクエストボディ(マスキング済み)
byte[] requestBody = request.getContentAsByteArray();
if (requestBody.length > 0) {
String body = new String(requestBody, StandardCharsets.UTF_8);
requestData.put("body", maskingService.maskJsonBody(truncate(body)));
}
logEntry.put("request", requestData);
// レスポンス情報
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("status", response.getStatus());
responseData.put("durationMs", duration);
byte[] responseBody = response.getContentAsByteArray();
if (responseBody.length > 0) {
String body = new String(responseBody, StandardCharsets.UTF_8);
responseData.put("body", maskingService.maskJsonBody(truncate(body)));
}
logEntry.put("response", responseData);
String jsonLog = objectMapper.writeValueAsString(logEntry);
log.info(jsonLog);
} catch (Exception e) {
log.error("Failed to create structured log", e);
}
}
private String truncate(String content) {
if (content.length() <= MAX_PAYLOAD_LENGTH) {
return content;
}
return content.substring(0, MAX_PAYLOAD_LENGTH) + "... [truncated]";
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/actuator") ||
path.startsWith("/static");
}
}
|
構造化ログの出力例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
{
"timestamp": "2026-01-04T08:00:00.123Z",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "HTTP_ACCESS",
"request": {
"method": "POST",
"uri": "/api/users",
"queryString": null,
"remoteAddress": "192.168.1.100",
"headers": {
"content-type": "application/json",
"authorization": "********"
},
"body": "{\"username\":\"john\",\"password\":\"********\"}"
},
"response": {
"status": 201,
"durationMs": 111,
"body": "{\"id\":1,\"username\":\"john\",\"accessToken\":\"********\"}"
}
}
|
パフォーマンスへの影響と対策#
リクエスト・レスポンスのログ出力は、パフォーマンスに影響を与える可能性があります。本番環境で運用する際の対策を紹介します。
パフォーマンス影響の要因#
リクエストログ出力がパフォーマンスに影響を与える主な要因は以下の通りです。
flowchart TD
A[パフォーマンス影響の要因] --> B[メモリ使用量]
A --> C[I/O負荷]
A --> D[CPU負荷]
B --> B1[大きなリクエスト/レスポンスボディのキャッシュ]
B --> B2[ログメッセージの文字列操作]
C --> C1[ログファイルへの書き込み]
C --> C2[同期的なログ出力]
D --> D1[JSONのパース/シリアライズ]
D --> D2[正規表現によるマスキング]対策1: 非同期ログ出力#
LogbackのAsyncAppenderを使用して、ログ出力を非同期化します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="ASYNC_CONSOLE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="CONSOLE" />
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
</appender>
<root level="INFO">
<appender-ref ref="ASYNC_CONSOLE" />
</root>
</configuration>
|
対策2: ペイロードサイズの制限#
大きなリクエスト・レスポンスボディをすべてキャッシュすると、メモリを圧迫します。適切なサイズ制限を設けましょう。
1
2
3
4
5
|
// キャッシュサイズを制限
private static final int MAX_PAYLOAD_LENGTH = 10000; // 10KB
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper(request, MAX_PAYLOAD_LENGTH);
|
対策3: サンプリング#
すべてのリクエストをログ出力するのではなく、一定割合のみをログ出力するサンプリングを導入します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SampledLoggingFilter extends OncePerRequestFilter {
private static final double SAMPLING_RATE = 0.1; // 10%のリクエストのみログ出力
private final Random random = new Random();
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// 基本的な除外条件
String path = request.getRequestURI();
if (path.startsWith("/actuator") || path.startsWith("/static")) {
return true;
}
// サンプリング: 確率的にフィルタリング
return random.nextDouble() > SAMPLING_RATE;
}
// ... doFilterInternal実装
}
|
対策4: 環境別の設定#
開発環境では詳細なログを、本番環境では最小限のログを出力するよう設定を分けます。
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
|
package com.example.demo.config;
import com.example.demo.filter.RequestResponseLoggingFilter;
import com.example.demo.filter.SecureRequestResponseLoggingFilter;
import com.example.demo.filter.LogMaskingService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
@Configuration
public class LoggingFilterConfig {
/**
* 開発環境: 詳細なログ出力(ボディ含む)
*/
@Bean
@Profile("dev")
public RequestResponseLoggingFilter devLoggingFilter() {
return new RequestResponseLoggingFilter();
}
/**
* 本番環境: マスキング付きの安全なログ出力
*/
@Bean
@Profile("prod")
public SecureRequestResponseLoggingFilter prodLoggingFilter(LogMaskingService maskingService) {
return new SecureRequestResponseLoggingFilter(maskingService);
}
/**
* ログ出力を完全に無効化するオプション
*/
@Bean
@ConditionalOnProperty(name = "app.logging.request.enabled", havingValue = "true", matchIfMissing = true)
public CommonsRequestLoggingFilter simpleLoggingFilter() {
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
filter.setIncludeQueryString(true);
filter.setIncludeClientInfo(true);
return filter;
}
}
|
対策5: 特定パスのみログ出力#
監査が必要なエンドポイントのみをログ出力対象とすることで、ログ量を削減します。
1
2
3
4
5
6
7
8
9
|
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
// 監査対象のパスのみログ出力
return !path.startsWith("/api/users") &&
!path.startsWith("/api/orders") &&
!path.startsWith("/api/payments");
}
|
まとめ#
本記事では、Spring Boot REST APIにおけるリクエストログ出力の実装方法を解説しました。
CommonsRequestLoggingFilterは、設定が簡単で開発環境でのデバッグに適していますが、レスポンスボディを取得できないという制限があります。
カスタムFilter + ContentCachingWrapperを使用することで、リクエスト・レスポンス両方のボディを完全に記録でき、本番環境での監査ログに適しています。
機密情報のマスキングは、本番環境では必須の対策です。JSONフィールド単位でのマスキングと、ヘッダーのマスキングを組み合わせることで、セキュリティを確保できます。
パフォーマンス対策として、非同期ログ出力、ペイロードサイズ制限、サンプリング、環境別設定を適切に組み合わせることが重要です。
リクエストログは、障害調査やセキュリティ監査において非常に重要な情報源です。要件に応じて適切な実装方法を選択し、安全かつ効率的なログ出力を実現してください。
参考リンク#