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


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 JSON | Response 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現場での判断基準
- ブラウザへ完成したHTMLを返すなら@Controller
- JavaScriptや外部システムへJSONを返すなら@RestController
- 一部だけ本文を返す既存画面Controllerなら@ResponseBodyを検討する
- ステータス・ヘッダーを明示したいならResponseEntityを検討する
- 画面とAPIが混在してきたらクラスを分ける
- 業務ロジックは共通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を支える技術
HTTP・URI・RESTを、Webアプリ開発の土台から理解する。
RequestParamやRequestBodyの暗記だけでは見えにくい、Web通信の基本原則を整理できます。
- HTTPの仕組みから理解したい
- RESTやURIの設計意図を学びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
@ControllerはView名を返す画面処理、@RestControllerは戻り値をJSONなどのレスポンス本文へ書くAPI処理に向いています。RestControllerにはResponseBodyの役割が含まれます。
画面とAPIをクラス単位で分け、共通の業務処理はServiceへ置きます。戻り値、Content-Type、ステータスをテストし、Entityではなく公開用DTOでAPIの契約を表しましょう。



