Spring Boot 3에서 @Controller와 @RestController의 차이를 View 렌더링/JSON 응답 관점에서 정리하고, ResponseEntity 사용 기준과 실무 컨벤션까지 예제로 확인합니다.
도입 (문제 상황)
페이지는 잘 떠야 하는데 갑자기 JSON이 내려오거나, 반대로 API를 만들었는데 뷰 이름을 찾다가 404가 나는 경험을 해 보셨을 거예요. 특히 “컨트롤러는 그냥 컨트롤러 아닌가?”라고 생각하고 @Controller와 @RestController를 섞어 쓰면, 작은 차이가 운영에서 큰 장애로 번지기도 합니다.
핵심 개념: Spring Boot에서 @Controller와 @RestController 차이가 중요한 이유
결론부터 말하면, 두 애노테이션의 차이는 “반환값을 무엇으로 해석하느냐”에 있습니다.
@Controller
기본적으로 View 렌더링(템플릿) 을 위한 컨트롤러입니다. 메서드가String을 반환하면 “응답 본문 문자열”이 아니라 뷰 이름으로 해석됩니다.
JSON을 내려주고 싶으면 메서드나 클래스에@ResponseBody를 붙여 “반환값을 HTTP Body로 쓰겠다”를 명시해야 합니다.@RestController@Controller + @ResponseBody의 합성 애노테이션입니다. 즉, 반환값은 기본적으로 항상 HTTP Body로 직렬화되어 내려갑니다(보통 JSON).
API 서버를 만들 때 의도가 분명해지고, 실수로 뷰 리졸버를 타는 사고를 줄여줍니다.
이 차이는 단순 취향 문제가 아니라, 요청 처리 파이프라인에서 어떤 HandlerMethodReturnValueHandler가 동작하느냐를 바꿉니다. “문자열을 반환했는데 왜 JSON이 아니지?” 같은 혼란은 여기서 시작합니다.
아래 표로 빠르게 정리해 보겠습니다.
| 구분 | @Controller | @RestController |
|---|---|---|
| 기본 목적 | View 렌더링(MVC) | API(JSON 등) |
| 반환값 기본 해석 | View 이름(예: "home") |
Response Body(JSON 직렬화) |
| JSON 응답 | @ResponseBody 필요 |
기본으로 JSON |
| 실수 포인트 | String 반환 시 뷰로 해석되어 404/템플릿 오류 |
View 렌더링하려다 문자열이 그대로 내려감 |
| 추천 사용처 | SSR/템플릿(Thymeleaf 등) | REST API, BFF API |
그리고 실무에서 자주 엮이는 주제가 ResponseEntity입니다.
ResponseEntity는 언제 쓰는 게 맞을까요?
ResponseEntity<T>는 “바디(T) + 상태코드 + 헤더”를 한 번에 제어하는 도구입니다. 다음 상황이면 쓰는 게 자연스럽습니다.
- 상태코드를 명확히 제어해야 할 때 (201 Created, 204 No Content, 404 Not Found 등)
- Location 헤더 같은 응답 헤더를 세팅해야 할 때
- 성공/실패 케이스에 따라 응답 형태가 달라질 때(단, 과도한 남발은 피하는 게 좋아요)
반대로, 항상 200 OK에 단순 JSON만 내려가는 API라면 DTO를 그대로 반환해도 충분합니다. 코드가 짧아지고, 읽기도 쉬워집니다.
flowchart TD
A["Controller method return value"] --> B{"Annotation type?"}
B -->| "@Controller" | C{"Has @ResponseBody?"}
C -->| "No" | D["ViewResolver renders template"]
C -->| "Yes" | E["HttpMessageConverter writes body (JSON)"]
B -->| "@RestController" | E

반환값이 뷰로 갈지(JSON로) 바디로 갈지 결정되는 흐름입니다.
코드 예제: View 렌더링(@Controller) vs JSON 응답(@RestController) + ResponseEntity 기준
아래 예제는 Spring Boot 3.x(Java 17) 기준으로 그대로 복붙해 실행할 수 있게 구성했습니다. /web은 뷰 렌더링, /api는 JSON API를 보여줍니다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // View 렌더링 예제용
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml
spring:
thymeleaf:
cache: false # 예제에서는 즉시 반영을 위해 비활성화(운영에서는 true 권장)
server:
port: 8080
실행 클래스
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApp {
public static void main(String[] args) {
SpringApplication.run(DemoApp.class, args);
}
}
View 렌더링 컨트롤러(@Controller)
package com.example.demo.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
@GetMapping("/web")
public String page(Model model) {
model.addAttribute("message", "This is a server-rendered view.");
return "home"; // templates/home.html 을 찾습니다.
}
@GetMapping("/web/json")
@org.springframework.web.bind.annotation.ResponseBody
public Message jsonFromController() {
// @Controller에서도 @ResponseBody를 붙이면 JSON 응답이 됩니다.
return new Message("JSON from @Controller + @ResponseBody");
}
public record Message(String text) {}
}
API 컨트롤러(@RestController) + ResponseEntity
package com.example.demo.api;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
@RestController
@RequestMapping("/api/messages")
public class MessageApiController {
@GetMapping("/{id}")
public MessageResponse get(@PathVariable long id) {
// 단순 조회: 200 OK + JSON이면 DTO 반환만으로 충분한 경우가 많습니다.
return new MessageResponse(id, "hello");
}
@PostMapping
public ResponseEntity<MessageResponse> create(@RequestBody CreateMessageRequest req) {
// 생성: 201 Created + Location 헤더를 주고 싶다면 ResponseEntity가 깔끔합니다.
long newId = 1L; // 예제용
var body = new MessageResponse(newId, req.text());
return ResponseEntity
.created(URI.create("/api/messages/" + newId))
.body(body);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
// 삭제 성공: 204 No Content는 ResponseEntity가 의도가 분명합니다.
return ResponseEntity.noContent().build();
}
public record CreateMessageRequest(String text) {}
public record MessageResponse(long id, String text) {}
}
Thymeleaf 템플릿: resources/templates/home.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Home</title>
</head>
<body>
<h1 th:text="${message}">placeholder</h1>
<p>Try: <a href="/web/json">/web/json</a></p>
<p>Try: <a href="/api/messages/1">/api/messages/1</a></p>
</body>
</html>
실행 후 확인
GET http://localhost:8080/web→ HTML(View 렌더링)GET http://localhost:8080/web/json→ JSON (Controller + ResponseBody)GET http://localhost:8080/api/messages/1→ JSON (RestController)POST http://localhost:8080/api/messageswith body{"text":"hi"}→ 201 + Location 헤더
실무 팁
💡 실무에서는: “패키지/URL로 역할을 분리”하는 컨벤션이 사고를 줄여줍니다
com.example.xxx.web패키지에는@Controller만 두고, URL도/web,/admin,/pages처럼 뷰 성격이 드러나게 잡아두면 혼선이 줄어듭니다.com.example.xxx.api패키지에는@RestController만 두고, URL도/api/**로 고정해 두면 “여긴 무조건 JSON”이라는 기대가 생깁니다.- 한 클래스에서 뷰와 API를 섞으면(특히
String반환) 리뷰/운영에서 실수가 자주 나옵니다.
💡 실무에서는: ResponseEntity를 “기본값”으로 두기보다 “필요할 때만” 쓰는 게 유지보수에 유리합니다
- 모든 메서드가
ResponseEntity<...>면 상태코드/헤더 제어가 쉬워 보이지만, 단순 조회까지 장황해져서 가독성이 떨어질 수 있습니다. - 추천 기준:
- 조회(항상 200) → DTO 반환
- 생성(201, Location) / 삭제(204) / 조건부 응답(404/409 등) → ResponseEntity
- 에러 응답은 컨트롤러에서 매번 처리하기보다
@RestControllerAdvice로 공통화하면 일관성이 좋아집니다(다음 글의 바인딩 주제와도 연결됩니다).
핵심 요약
@Controller는 기본이 View 렌더링,@RestController는 기본이 JSON(Response Body)입니다.ResponseEntity는 상태코드/헤더를 “의도적으로” 제어해야 할 때 특히 유용합니다.- 패키지/URL 컨벤션으로 View와 API를 분리하면 운영 사고가 크게 줄어듭니다.
다음 글: [#11 요청/응답 바인딩(@RequestBody, @ModelAttribute) 실전]