はじめに
Spring BootでJPAエンティティを @RestController からそのまま返すコードは、チュートリアルやサンプルコードでは頻繁に見かけます。しかし本番環境でこれを行うと、意図しないフィールドがレスポンスに含まれてしまうリスクが常につきまといます。
何が起きるか
// ❌ エンティティをそのまま返す
@GetMapping("/customers/{id}")
public Customer getCustomer(@PathVariable Long id) {
return customerRepository.findById(id)
.orElseThrow(() -> new CustomerNotFoundException(id));
}
@Entity
public class Customer {
private Long id;
private String name;
private String email;
private String passwordHash; // ← これも一緒にシリアライズされる
private String internalMemo; // ← 社内向けメモも
}
Jacksonはデフォルトで、getterが存在する全フィールドをシリアライズします。開発者が「passwordHashは返したくない」と思っていても、@JsonIgnore などの明示的な除外設定を忘れれば、そのままレスポンスに乗ってしまいます。
このリスクは以下のような場面で顕在化します。
- 新しくエンティティにフィールドを追加した際、除外設定を入れ忘れる
- 別の開発者がエンティティを直接返す別のエンドポイントを追加し、そちらには除外設定がない
- エンティティ間のリレーション(
@OneToManyなど)を辿って、意図しない関連エンティティまで丸ごとシリアライズされてしまう(N+1問題と情報漏洩が同時に起きる)
対策: レスポンス専用のDTOを必ず経由する
// ✅ レスポンスDTOに変換して返す
public record CustomerResponse(
Long id,
String name,
String email,
Instant createdAt
) {}
@GetMapping("/customers/{id}")
public CustomerResponse getCustomer(@PathVariable Long id) {
Customer customer = customerRepository.findById(id)
.orElseThrow(() -> new CustomerNotFoundException(id));
return mapper.toCustomerResponse(customer);
}
public CustomerResponse toCustomerResponse(Customer customer) {
return new CustomerResponse(
customer.getId(),
customer.getName(),
customer.getEmail(),
customer.getCreatedAt()
// passwordHash, internalMemo は意図的に含めない
);
}
DTOには「返したいフィールドだけ」を明示的に列挙します。エンティティに新しいフィールドが追加されても、DTOのコンストラクタに追加しない限りレスポンスには一切現れません。つまり、デフォルトの安全側(何も返さない)から、開発者が意図的に「返す」と決めたものだけを公開する設計になります。
@JsonIgnore との違い
@JsonIgnore をエンティティに付与する方法でも同様の効果は得られますが、以下の理由でDTO変換の方が安全です。
- エンティティは複数のAPIやバッチ処理から使われることが多く、あるユースケースでは見せたい・別のユースケースでは隠したい、という要求に対応できない
- エンティティの責務(永続化)とレスポンスの責務(クライアントへの公開範囲)が混在し、変更理由が異なるものを1つのクラスに詰め込むことになる
- レスポンスDTOであれば、APIのバージョンごとに異なるDTOを用意するといった拡張がしやすい
まとめ
「エンティティをそのまま返す」実装は、動作確認の初期段階では問題が表面化しません。しかしフィールドが増え、開発者が増えるにつれて、除外設定の付け忘れという形で確実に事故が起こります。レスポンスDTOを必ず経由させ、返すフィールドをホワイトリスト方式で明示することが、機密情報漏洩を構造的に防ぐ最も確実な方法です。