Spring BootのControllerとRestControllerの違いを解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがHTML画面とJSON APIを学ぶイラスト。

実装・エラー解決

Spring Bootの@Controllerと@RestControllerの違い|画面とJSONで使い分ける

AIによる要約

@Controllerは主にThymeleafなどの画面名を返し、@RestControllerは戻り値をレスポンス本文へ書き込みます。RestControllerはControllerとResponseBodyを組み合わせたアノテーションです。HTML画面とJSON APIをクラス単位で分け、HTTPステータスやヘッダーも返したいAPIではResponseEntityを使うと、戻り値の意図が分かりやすくなります。

新人SE
新人SE
Controllerから"users/list"をreturnしたのに、画面ではなく文字列がそのまま表示されました。
ポンコツSE
ポンコツSE
クラスに@RestControllerが付いていないか確認してください。その戻り値はテンプレート名ではなくレスポンス本文として扱われます。

Spring BootのWeb開発では、@Controllerと@RestControllerのどちらでもURLとメソッドを対応付けられます。違いは「戻り値を画面名として扱うか、レスポンス本文として扱うか」です。

アノテーションを雰囲気で選ぶと、Thymeleafのつもりで文字列を返したのにブラウザへその文字だけが表示される、APIのつもりなのにView検索エラーになる、といった不具合が起きます。役割と戻り値をセットで理解しましょう。

この記事のポイント

  • HTML画面を返すクラスは@Controllerを基本にする
  • JSON APIを返すクラスは@RestControllerを基本にする
  • RestControllerはControllerとResponseBodyを組み合わせたもの
  • 一部メソッドだけ本文を返すなら@ResponseBodyを使える
  • HTTPステータスやヘッダーも表すならResponseEntityを検討する

実行用サンプル

画面名、HTML、文字列本文、JSON、201の応答を、同じ社員データを使うMockMvcテストで比較します。 ソース全文、JUnitの検証コード、pom.xml、READMEを収録しています。JDK 17とMaven 3.9系で mvn test を実行します。Spring Boot 3.5.16のサンプルです。

@Controllerは画面名を返す

EmployeePageController.java の抜粋。HTML用の詳細メソッド

    @GetMapping("/{id}")
    public String detail(@PathVariable long id, Model model) {
        model.addAttribute("employee", employees.find(id));
        return "employees/detail";
    }

@Controller の GET /employees/10 は、モデルへ社員を入れて employees/detail を返します。Thymeleafが templates/employees/detail.html からHTMLを作るため、画面本文に田中が表示されます。

入力画面、確認画面、一覧画面のようにサーバー側でHTMLを組み立てる場合は@Controllerが分かりやすい選択です。

@RestControllerは戻り値を本文へ書く

EmployeeApiController.java の抜粋。JSON用の詳細メソッド

    @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
    public EmployeeResponse detail(@PathVariable long id) {
        return EmployeeResponse.from(employees.find(id));
    }

@RestController の GET /api/employees/10 は同じ社員を EmployeeResponse に変換します。この構成ではメッセージコンバーターがDTOをJSONの本文へ書きます。

JSONの本文には id、name、status が入り、今回は 10、田中、ACTIVE を返します。

@RestControllerは、クラス全体へ@Controllerと@ResponseBodyを付けたのと同じ意図を表します。RESTという名前ですが、「どのようなAPI設計でも自動的にRESTfulになる」という意味ではありません。

違いを表で確認する

観点@Controller@RestController
主な用途HTML画面JSONなどのAPI
Stringの戻り値View名本文の文字列
オブジェクトの戻り値通常はView処理を検討JSONなどへ変換
Model画面へ値を渡すため使用通常は使わない
@ResponseBody必要なメソッドだけ付けるクラス全体に含まれる

文字列がそのまま表示される原因

ReturnValueExamples.java の抜粋。画面名のような文字列を本文にする例

@RestController
class RestReturnExamples {
    @GetMapping(value = "/examples/rest-view-name", produces = MediaType.TEXT_PLAIN_VALUE)
    String asBody() {
        return "employees/list";
    }
}

GET /examples/rest-view-name は、テンプレートをレンダリングせず、employees/list という文字列を text/plain で返します。テストでもビュー情報が空であることを確認します。

修正はクラスを@Controllerへ変えることです。メソッドへViewResolver関連の設定を足して無理に解決するより、そのクラスが画面用かAPI用かを明確にします。

JSONのつもりなのにViewを探す原因

ReturnValueExamples.java の抜粋。@ResponseBodyのないメソッド

    @GetMapping("/examples/page-map")
    Map<String, String> asModel() {
        return Map.of("status", "UP");
    }

GET /examples/page-map では、返したMapの status がModelへ入ります。Spring MVCがリクエストパスから examples/page-map をビュー名として決め、ThymeleafのHTMLを返します。対応するテンプレートがなければ、ビュー解決や描画を調べます。

ReturnValueExamples.java の抜粋。同じMapを本文にするメソッド

    @ResponseBody
    @GetMapping("/examples/page-body")
    Map<String, String> asBody() {
        return Map.of("status", "UP");
    }

GET /examples/page-body は @ResponseBody があるので、同じMapがJSON本文になります。クラス全体を本文返却に使うと決まっている場合は @RestController を使えます。

画面用とAPI用のControllerを分ける

同じ社員を扱っても、画面の /employees とAPIの /api/employees では入出力が異なります。画面の入力エラーは BindingResult を使ってフォームへ戻し、APIの入力エラーはHTTP 400として扱います。

両方のControllerは同じ EmployeeDirectory を呼びます。画面側はModelとテンプレートを組み合わせ、API側は公開用DTOを作ります。

Controllerは「同じ業務なら一つ」ではなく、「同じ入出力方式なら一つ」と考えると分けやすくなります。

ResponseEntityはいつ使うか

EmployeeApiController.java の抜粋。APIの登録メソッド

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<EmployeeResponse> create(@Valid @RequestBody EmployeeForm form) {
        var employee = employees.create(form.name());
        return ResponseEntity.created(URI.create("/api/employees/" + employee.id()))
                .body(EmployeeResponse.from(employee));
    }

この登録APIは ResponseEntity で201、Location、DTOを返します。テストでは本文のIDと Location が一致し、そのURLをGETすると新しく作った社員を取得できることを確かめます。

場面戻り値の候補
Thymeleaf画面View名のString
単純な200 JSONResponse DTO
201・204・ヘッダーを明示ResponseEntity
ファイルダウンロードResponseEntityやResource

EntityをそのままJSONへ返さない

EmployeeApiController.java の抜粋。公開項目だけを定義するレスポンスDTO

record EmployeeResponse(long id, String name, String status) {
    static EmployeeResponse from(Employee employee) {
        return new EmployeeResponse(employee.id(), employee.name(), employee.status());
    }
}

サンプルの内部データ Employee には internalNote があります。APIに必要な id、name、status だけをDTOへ移し、internalNote がJSONにないことをテストします。JPAのEntityでも、公開範囲はDTOで決めます。

Content-Typeも確認する

MockMvcで view().name(...)、content().contentTypeCompatibleWith(...)、jsonPath(...) を使い、同じ社員を画面とAPIで別々に確かめます。画面側ではビュー名だけでなく、HTMLに社員名が入るところまで見ます。

ReturnValueTest.java の全文

package info.syachiku.employee;

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class ReturnValueTest {
    @Autowired
    MockMvc mvc;

    @Test
    void 同じ社員を画面ではview名とHTMLでAPIではJSONで返す() throws Exception {
        mvc.perform(get("/employees/10"))
                .andExpect(status().isOk())
                .andExpect(view().name("employees/detail"))
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(content().string(containsString("田中")));
        mvc.perform(get("/api/employees/10"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(10))
                .andExpect(jsonPath("$.name").value("田中"))
                .andExpect(jsonPath("$.status").value("ACTIVE"))
                .andExpect(jsonPath("$.internalNote").doesNotExist());
    }

    @Test
    void RestControllerのStringはView名ではなく本文になる() throws Exception {
        mvc.perform(get("/examples/rest-view-name"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN))
                .andExpect(content().string("employees/list"))
                .andExpect(result -> assertNull(result.getModelAndView()));
    }

    @Test
    void MapはControllerではModelになりResponseBodyではJSONになる() throws Exception {
        mvc.perform(get("/examples/page-map"))
                .andExpect(status().isOk())
                .andExpect(view().name("examples/page-map"))
                .andExpect(model().attribute("status", "UP"))
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
        mvc.perform(get("/examples/page-body"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value("UP"));
    }

    @Test
    void API登録は201と新しい社員のLocationを返す() throws Exception {
        var response = mvc.perform(post("/api/employees").contentType(MediaType.APPLICATION_JSON)
                        .content("{\"name\":\"佐藤\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("佐藤"))
                .andReturn().getResponse();

        Number id = JsonPath.read(response.getContentAsString(), "$.id");
        String location = response.getHeader("Location");
        assertThat(location).isEqualTo("/api/employees/" + id.longValue());
        mvc.perform(get(location)).andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("佐藤"));
    }
}

この章の ReturnValueTest は4件です。同じZIPの HttpMethodTest は、GETとPOSTが社員データへ与える影響を5件で調べます。全体の実行結果は次のとおりです。

Tests run: 9, Failures: 0, Errors: 0, Skipped: 0

現場での判断基準

  1. ブラウザへ完成したHTMLを返すなら@Controller
  2. JavaScriptや外部システムへJSONを返すなら@RestController
  3. 一部だけ本文を返す既存画面Controllerなら@ResponseBodyを検討する
  4. ステータス・ヘッダーを明示したいならResponseEntityを検討する
  5. 画面とAPIが混在してきたらクラスを分ける
  6. 業務ロジックは共通Serviceへ置く

現場レビューでよくある指摘

// レビューコメント例
ThymeleafのView名を返していますが、クラスが@RestControllerです。
画面用なら@Controllerへ変更してください。

// レビューコメント例
画面表示とJSON APIが同じControllerに混在しています。
URL・例外処理・テストの責務を分けるためクラスを分割してください。

// レビューコメント例
JPA Entityをそのままレスポンスへ返しています。
公開項目をResponse DTOで定義し、遅延関連や内部項目を出さないようにしてください。

提出前のセルフチェック

  • 戻り値をView名と本文のどちらとして扱うか明確か
  • 画面には@Controller、APIには@RestControllerを選んだか
  • 画面用とAPI用のクラスが混在していないか
  • APIでEntityを直接返していないか
  • 必要なHTTPステータスとヘッダーを表せているか
  • Content-Typeをテストしたか
  • 業務ロジックがControllerへ重複していないか

WebとHTTPの土台を学ぶ参考書

Controllerのアノテーションだけでなく、HTTPメソッド、ステータス、ヘッダー、リソース設計を理解すると、画面とAPIの戻り値を判断しやすくなります。

書籍「Webを支える技術」の表紙
商品画像:Amazon.co.jp
PR Web開発に入る新人向け

Webを支える技術

山本 陽平 (著)

HTTP・URI・RESTを、Webアプリ開発の土台から理解する。

RequestParamやRequestBodyの暗記だけでは見えにくい、Web通信の基本原則を整理できます。

  • HTTPの仕組みから理解したい
  • RESTやURIの設計意図を学びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事Spring BootのControllerでやってはいけないこと Controllerへ業務ロジックを詰め込まないための責務分担を解説します。 記事を読む 関連記事Spring Bootの@RequestBodyとは?JSONを受け取る使い方と注意点 RestControllerの入力側でJSONをFormへ変換する基本を確認できます。 記事を読む 関連記事Spring Bootの@GetMappingと@PostMappingの違い|@RequestMappingとの使い分け 画面とAPIで使うHTTPメソッドの役割を整理できます。 記事を読む

まとめ

@ControllerはView名を返す画面処理、@RestControllerは戻り値をJSONなどのレスポンス本文へ書くAPI処理に向いています。RestControllerにはResponseBodyの役割が含まれます。

画面とAPIをクラス単位で分け、共通の業務処理はServiceへ置きます。戻り値、Content-Type、ステータスをテストし、Entityではなく公開用DTOでAPIの契約を表しましょう。

-実装・エラー解決
-,