Spring BootでControllerを書き始めると、必ず最初にぶつかるのが「クライアントから送られてきた値をどうやって受け取るか」ですよね。クエリ文字列、URLのパス、JSONボディ、フォーム。入力の場所ごとに使うアノテーションが違うので、最初のうちは @RequestParam@PathVariable のどっちだっけ、となりがちです。

この記事では、入力が「どこにあるか」を起点にして、4つの主要なアノテーションを迷わず選べるように整理していきます。

リクエスト入力の4つの所在と対応アノテーション早見表

まず結論から見てしまいましょう。受け取りたい値がリクエストのどこにあるかで、使うアノテーションはほぼ決まります。

入力の所在使うアノテーション
クエリ文字列の単一値?keyword=spring@RequestParam
URLパスの一部/users/42@PathVariable
JSONボディ{"name":"太郎"}@RequestBody
フォーム/複数パラメータをまとめる?page=1&size=20@ModelAttribute

判断軸はシンプルで、「値がURLのどこにあるか、それともボディにあるか」を見るだけです。以降の章でそれぞれの使い方と、つまずきやすいポイントを順番に見ていきます。

@RequestParam:クエリ文字列やフォームの単一パラメータを受け取る

?keyword=spring のようなクエリパラメータを受け取るときは @RequestParam を使います。

@GetMapping("/search")
public String search(@RequestParam String keyword) {
    return "keyword = " + keyword;
}

/search?keyword=spring にアクセスすると keywordspring が入ります。パラメータ名とメソッド引数名が同じなら、valuename の指定は省略できます。

ちなみに @RequestParam はGETのクエリだけでなく、application/x-www-form-urlencoded で送られてきたフォームの単一パラメータも同じように受け取れます。POSTのフォーム送信でも動く、と覚えておくと便利です。

required・defaultValue・名前指定の使い分け

実務で詰まりやすいのが必須/任意やデフォルト値の扱いです。@RequestParam はデフォルトで 必須 なので、パラメータが無いと400エラーになります。

@GetMapping("/search")
public String search(
        @RequestParam(required = false) String keyword,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam("q") String query) {
    return keyword + " / " + size + " / " + query;
}

required = false にすると任意になり、未指定のときは null が入ります。ただし int のようなプリミティブ型に null は代入できないので、任意にしたい数値は Integer などのラッパー型にするか defaultValue を使いましょう。

defaultValue を指定すると、そのパラメータは自動的に任意扱いになり、未指定時にはその値が補われます。引数名とパラメータ名が違うときは @RequestParam("q") のように名前を明示します。

任意パラメータは Optional<String> で受け取ることもできます。null チェックを明示的に書きたいときはこちらも選択肢になります。

複数値をListやT[]で受け取る

?ids=1&ids=2&ids=3 のように同じ名前のパラメータが複数来る場合は、List で受け取れます。

@GetMapping("/items")
public String items(@RequestParam List<Long> ids) {
    return "count = " + ids.size();
}

List<Long> の代わりに Long[] の配列でも受け取れます。

ここで一つ注意点があります。?ids=1,2,3 のようにカンマ区切りの単一パラメータで送った場合も、Springは要素3つの List に分解してくれます。ただしこの2つは送り方が違うだけで挙動が近いので混同しがちです。クライアント側の仕様がどちらなのかは事前に揃えておくと安全です。

@PathVariable:URLパスに埋め込まれた値を受け取る

リソースを一意に特定するIDなどは、クエリではなくパスに埋め込むのがREST APIの定石です。パスに含まれる値は @PathVariable で受け取ります。

@GetMapping("/users/{id}")
public String getUser(@PathVariable Long id) {
    return "user id = " + id;
}

@GetMapping("/users/{userId}/posts/{postId}")
public String getPost(
        @PathVariable("userId") Long uid,
        @PathVariable("postId") Long pid) {
    return uid + " / " + pid;
}

{id} のように波括弧で書いた部分がパス変数です。テンプレートの変数名と引数名が同じなら省略でき、違うときは @PathVariable("userId") のように名前を明示します。

使い分けの指針としては、リソースを特定するID系はパス、絞り込みや並び替えの条件はクエリ と考えると整理しやすいです。/users/42 は「42番のユーザー」、/users?role=admin は「adminで絞り込んだユーザー一覧」というイメージですね。REST APIの全体像は Spring BootでREST APIのCRUDを作るチュートリアル も参考にしてください。

@RequestBody:JSONボディをオブジェクトにマッピングする

POSTやPUTでJSONを送るAPIでは、ボディ全体を1つのオブジェクトに受け取ります。ここで使うのが @RequestBody です。

@PostMapping("/users")
public String create(@RequestBody UserRequest request) {
    return "name = " + request.getName();
}

受け取り用のDTOはこんな形です。

public class UserRequest {
    private String name;
    private int age;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}

対応するリクエストはこのようになります。

POST /users
Content-Type: application/json

{
  "name": "太郎",
  "age": 30
}

内部ではJacksonがJSONを読み取り、フィールド名を頼りにオブジェクトへ変換してくれます。変換の細かい設定は Spring BootのJacksonでJSONを扱う方法 にまとめています。

@RequestBodyでJSONが受け取れない典型原因

「値が全部nullになる」「そもそも400が返る」といった相談は、だいたい次のどれかです。

  • Content-Typeが付いていないapplication/json を送らないとSpringはJSONとして扱ってくれません。
  • DTOにgetter/setterや引数なしコンストラクタが無い。Jacksonがデシリアライズできず失敗します。Lombokの @Data などで揃えるのも手です。
  • @RequestBody の付け忘れ。付け忘れるとボディが読まれず、フィールドがnullのままになります。
  • フィールド名とJSONキーの不一致userNameuser_name のようなズレは、そのフィールドだけnullになります。

全部nullなら @RequestBody かContent-Type、一部だけnullならフィールド名、と切り分けると原因にたどり着きやすいです。

@ModelAttribute:複数のフォーム/クエリパラメータをまとめる

検索フォームのように、パラメータが複数あってそれを1つのオブジェクトにまとめたいときは @ModelAttribute が便利です。

@GetMapping("/search")
public String search(@ModelAttribute SearchForm form) {
    return form.getKeyword() + " / " + form.getPage();
}

SearchFormkeywordpage のフィールドがあれば、?keyword=spring&page=2 がそのままバインドされます。実はDTOのような複雑型の引数はデフォルトで @ModelAttribute 扱いになるので、アノテーション自体は省略できることも多いです。

@RequestBody との違いは対象とする形式です。@RequestBody はJSONボディ、@ModelAttribute はフォームやクエリ形式のパラメータを見ます。JSONを @ModelAttribute で受けようとしても値が入らないので、ここは混同しないようにしましょう。

型変換失敗時の挙動:400 Bad Requestと発生する例外

@RequestParam Long id に対して ?id=abc のような数値でない値が来たら、Springは型変換に失敗します。このとき MethodArgumentTypeMismatchException が投げられ、デフォルトでは 400 Bad Request が返ります。@PathVariable でも同様です。

一方 @RequestBody でJSONの構文が壊れていたり型が合わなかったりすると HttpMessageNotReadableException が発生し、こちらもデフォルトで400になります。

つまり、入力の形式が正しくないケースはSpringが標準で400として弾いてくれます。エラーレスポンスの中身を整えたい場合は @ExceptionHandler@ControllerAdvice を使いますが、それはまた別のテーマなのでここでは触れません。

どのアノテーションを使うか:判断フローのまとめ

最後に選び方を一本の流れにまとめておきます。

  1. 値がURLパスの一部(/users/42)なら → @PathVariable
  2. ? の後ろのクエリで単一の値なら → @RequestParam
  3. JSONボディをまるごとオブジェクトにするなら → @RequestBody
  4. 複数のフォーム/クエリを1つのオブジェクトにまとめるなら → @ModelAttribute

迷ったら「値の置き場所はどこか」に立ち返れば、ほぼ一択に絞れます。受け取れるようになったら、次は受け取った値が正しいかを確かめる入力検証です。そちらは @Validでバリデーションを行う方法 に進むと、Controllerの入口がぐっと堅牢になりますよ。