Spring Boot를 배우다 보면 @Controller와 @RestController 두 가지 어노테이션을 만나게 됩니다. 둘 다 컨트롤러인데 뭐가 다른 걸까요? 그냥 @RestController만 쓰면 되는 거 아닌가요?
처음엔 저도 그렇게 생각했습니다. 하지만 이 둘의 차이를 이해하면, 서버가 클라이언트에게 무엇을 돌려주는지에 대한 근본적인 개념이 잡히게 됩니다. 오늘은 최대한 쉽게 이 차이를 설명해보겠습니다.
먼저, 서버가 할 수 있는 두 가지 일
웹 서버는 크게 두 가지 종류의 응답을 할 수 있습니다:
- 데이터만 주는 서버 (API 서버)
- 화면(HTML)을 주는 서버 (웹 애플리케이션 서버)
음식점으로 비유하면 이렇습니다:
- API 서버: 재료만 파는 곳입니다. "소고기 200g 주세요" 하면 소고기만 딱 줍니다. 요리는 손님이 직접 해야 합니다.
- 화면 서버: 완성된 요리를 주는 곳입니다. "스테이크 주세요" 하면 접시에 담긴 스테이크가 나옵니다.
이 두 가지 서버를 Spring에서 어떻게 만드는지 살펴보겠습니다.
@RestController: 데이터만 주는 API 서버
기본 예시
@RestController
public class UserApiController {
@GetMapping("/api/users/1")
public User getUser() {
User user = new User();
user.setId(1L);
user.setName("홍길동");
user.setEmail("hong@example.com");
return user;
}
}
이 코드에서 /api/users/1에 접속하면 어떤 응답이 올까요?
{
"id": 1,
"name": "홍길동",
"email": "hong@example.com"
}
바로 JSON 데이터가 옵니다. HTML도 아니고, 화면도 아니고, 그냥 데이터 덩어리입니다.
왜 JSON일까?
@RestController를 사용하면 Spring이 자동으로 객체를 JSON으로 변환해줍니다. 이 과정을 직렬화(Serialization)라고 합니다. Spring Boot에는 기본적으로 Jackson이라는 라이브러리가 포함되어 있어서, 이 변환 작업을 알아서 처리해줍니다. (나중에 에러 메시지에서 Jackson을 보게 되면 "아, JSON 변환하다 문제가 생겼구나"라고 이해하면 됩니다.)
User 객체 → Jackson이 변환 → JSON 문자열
이 JSON 데이터를 받아서 화면을 만드는 건 누구의 몫일까요? 바로 프론트엔드(클라이언트)입니다. React, Vue, 모바일 앱 등이 이 JSON을 받아서 예쁜 화면으로 만들어줍니다.
@RestController의 정체
@RestController는 사실 두 개의 어노테이션을 합친 것입니다:
@Controller
@ResponseBody
public class UserApiController {
// ...
}
위 코드와 @RestController는 완전히 동일합니다. @ResponseBody가 핵심인데, 이 어노테이션이 "메서드가 반환하는 값을 그대로 HTTP 응답 본문에 써라"라고 Spring에게 알려줍니다.
@Controller: 화면(HTML)을 주는 서버
기본 예시
@Controller
public class UserController {
@GetMapping("/users/1")
public String getUser(Model model) {
User user = new User();
user.setId(1L);
user.setName("홍길동");
user.setEmail("hong@example.com");
model.addAttribute("user", user);
return "user/profile"; // 뷰 이름을 반환
}
}
이 코드에서 /users/1에 접속하면 어떤 응답이 올까요?
<!DOCTYPE html>
<html>
<head>
<title>회원 정보</title>
</head>
<body>
<h1>홍길동</h1>
<p>이메일: hong@example.com</p>
</body>
</html>
JSON이 아니라 HTML 페이지가 옵니다. 브라우저에서 바로 볼 수 있는 완성된 화면입니다.
어떻게 HTML이 만들어질까?
여기서 뷰 리졸버(View Resolver)와 템플릿 엔진이 등장합니다.
위 코드에서 return "user/profile"은 "user/profile이라는 이름의 템플릿을 찾아서 HTML로 만들어줘"라는 의미입니다.
실제 동작 과정을 보면:
1. 컨트롤러가 "user/profile" 반환
2. 뷰 리졸버가 이 이름을 실제 파일 경로로 변환
→ "user/profile" → "/templates/user/profile.html"
3. 템플릿 엔진(Thymeleaf 등)이 HTML 파일을 읽음
4. model에 담긴 데이터(user)를 HTML에 끼워넣음
5. 완성된 HTML을 클라이언트에게 전송
템플릿 파일 예시
/templates/user/profile.html (Thymeleaf 사용 시):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>회원 정보</title>
</head>
<body>
<h1 th:text="${user.name}">이름</h1>
<p>이메일: <span th:text="${user.email}">이메일</span></p>
</body>
</html>
th:text="${user.name}"이 model.addAttribute("user", user)로 전달한 user 객체의 name 값으로 치환됩니다.
뷰 리졸버(View Resolver)란?
뷰 리졸버는 컨트롤러가 반환한 문자열(뷰 이름)을 실제 화면(View)으로 연결해주는 역할을 합니다.
쉽게 말해 번역가입니다:
"user/profile" → "야, /templates/user/profile.html 파일 가져와"
Spring Boot에서 Thymeleaf를 사용하면 기본 설정은 이렇습니다:
- 접두사(prefix):
classpath:/templates/ - 접미사(suffix):
.html
그래서 "user/profile"을 반환하면:
classpath:/templates/ + user/profile + .html
= classpath:/templates/user/profile.html
이 경로의 파일을 찾아서 렌더링합니다.
설정 변경하기
application.properties에서 설정을 바꿀 수 있습니다:
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
정적 리소스(Static Resources)란?
웹사이트에는 변하지 않는 파일들이 있습니다:
- CSS 파일
- JavaScript 파일
- 이미지 파일
- 폰트 파일
이런 파일들을 정적 리소스라고 합니다. "정적"이라는 말은 서버가 가공하지 않고 그대로 전달한다는 의미입니다.
정적 리소스의 위치
Spring Boot에서 정적 리소스는 기본적으로 이 위치에 둡니다:
src/main/resources/static/
예를 들어:
src/main/resources/static/
├── css/
│ └── style.css
├── js/
│ └── app.js
└── images/
└── logo.png
이 파일들은 별도의 컨트롤러 없이 바로 접근할 수 있습니다:
/css/style.css→static/css/style.css파일 반환/images/logo.png→static/images/logo.png파일 반환
참고로 static/index.html 파일을 만들어두면, 루트 경로(/)에 접속했을 때 자동으로 이 파일이 보여집니다. Spring Boot가 웰컴 페이지로 인식하기 때문입니다.
정적 리소스 vs 템플릿
헷갈리기 쉬운 부분이니 정리하면:
| 구분 | 정적 리소스 | 템플릿 |
|---|---|---|
| 위치 | /static/ |
/templates/ |
| 가공 여부 | 그대로 전달 | 데이터를 끼워넣어서 전달 |
| 예시 | CSS, JS, 이미지 | HTML (Thymeleaf) |
| 접근 방식 | URL로 직접 접근 | 컨트롤러를 통해 접근 |
실제 코드로 비교해보기
같은 기능을 @Controller와 @RestController로 각각 구현해보겠습니다.
@RestController 방식 (API 서버)
@RestController
@RequestMapping("/api")
public class ProductApiController {
@GetMapping("/products")
public List<Product> getProducts() {
List<Product> products = new ArrayList<>();
products.add(new Product(1L, "노트북", 1500000));
products.add(new Product(2L, "마우스", 50000));
products.add(new Product(3L, "키보드", 100000));
return products;
}
}
응답:
[
{"id": 1, "name": "노트북", "price": 1500000},
{"id": 2, "name": "마우스", "price": 50000},
{"id": 3, "name": "키보드", "price": 100000}
]
이 데이터를 받아서 화면을 만드는 건 프론트엔드(React, Vue 등)의 몫입니다.
@Controller 방식 (화면 서버)
@Controller
public class ProductController {
@GetMapping("/products")
public String getProducts(Model model) {
List<Product> products = new ArrayList<>();
products.add(new Product(1L, "노트북", 1500000));
products.add(new Product(2L, "마우스", 50000));
products.add(new Product(3L, "키보드", 100000));
model.addAttribute("products", products);
return "product/list";
}
}
템플릿 파일 (/templates/product/list.html):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>상품 목록</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<h1>상품 목록</h1>
<ul>
<li th:each="product : ${products}">
<span th:text="${product.name}">상품명</span> -
<span th:text="${product.price}">가격</span>원
</li>
</ul>
</body>
</html>
응답: 완성된 HTML 페이지가 바로 브라우저에 표시됩니다.
@Controller에서도 JSON을 반환하고 싶다면?
@Controller를 사용하면서도 특정 메서드만 JSON을 반환하고 싶을 때가 있습니다. 이럴 때는 @ResponseBody를 메서드에 붙이면 됩니다:
@Controller
public class ProductController {
// HTML 반환
@GetMapping("/products")
public String getProducts(Model model) {
// ...
return "product/list";
}
// JSON 반환
@GetMapping("/products/json")
@ResponseBody
public List<Product> getProductsAsJson() {
// ...
return products;
}
}
@ResponseBody가 붙은 메서드는 뷰 리졸버를 거치지 않고, 반환값을 직접 HTTP 응답 본문에 씁니다.
언제 뭘 써야 할까?
@RestController를 사용하는 경우
- 프론트엔드와 백엔드가 분리된 경우
- React, Vue, Angular 등 SPA(Single Page Application) 프레임워크 사용 시
- 모바일 앱에 데이터를 제공할 때
- 다른 서버나 시스템과 데이터를 주고받는 경우
- 마이크로서비스 간 통신
- 외부 API 제공
[React 앱] ←JSON→ [Spring API 서버]
[모바일 앱] ←JSON→ [Spring API 서버]
@Controller를 사용하는 경우
- 서버에서 직접 HTML을 만들어서 주는 경우
- 전통적인 웹 애플리케이션
- 서버 사이드 렌더링(SSR)
- 관리자 페이지 등 단순한 화면
[브라우저] ←HTML→ [Spring 서버 + Thymeleaf]
현업에서는?
요즘 대부분의 신규 프로젝트는 프론트엔드와 백엔드를 분리하는 추세입니다. 그래서 @RestController를 더 많이 사용하게 됩니다.
하지만 @Controller가 필요 없는 건 아닙니다:
- 간단한 관리자 페이지
- 이메일 템플릿 생성
- 프론트엔드 개발자 없이 빠르게 프로토타입 만들 때
상황에 맞게 선택하면 됩니다.
자주 하는 실수
1. @Controller에서 문자열 반환이 안 될 때
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home"; // templates/home.html을 찾음
}
}
에러가 난다면? 대부분 이런 경우입니다:
templates/home.html파일이 없음- Thymeleaf 의존성이 없음 (
spring-boot-starter-thymeleaf)
2. @RestController인데 HTML을 반환하고 싶을 때
@RestController
public class HomeController {
@GetMapping("/")
public String home() {
return "home"; // 그냥 "home"이라는 문자열이 반환됨
}
}
@RestController는 @ResponseBody가 이미 적용되어 있어서, "home"이라는 문자열 자체가 응답으로 나갑니다. 화면을 반환하려면 @Controller를 사용해야 합니다.
3. 정적 리소스가 안 보일 때
src/main/resources/static/css/style.css
이 파일에 접근하려면 /static/css/style.css가 아니라 /css/style.css입니다. static 폴더는 URL에 포함되지 않습니다.
4. Circular View Path 에러
@Controller
public class UserController {
@GetMapping("/user")
public String user() {
return "user"; // URL과 뷰 이름이 같음
}
}
URL 매핑 이름(/user)과 반환하는 뷰 이름(user)이 같으면 Circular View Path 에러가 발생할 수 있습니다. 뷰를 찾으러 갔더니 다시 자기 자신을 호출하는 무한 루프에 빠지는 것입니다. 뷰 이름을 user/profile처럼 다르게 하거나, URL을 /users처럼 다르게 지정하면 해결됩니다.
정리
| 구분 | @Controller | @RestController |
|---|---|---|
| 반환 | 뷰 이름 (HTML) | 데이터 (JSON) |
| 동작 | 뷰 리졸버가 템플릿을 찾아서 렌더링 | 객체를 JSON으로 변환해서 반환 |
| 용도 | 서버 사이드 렌더링 | REST API |
| 실제 구성 | @Controller | @Controller + @ResponseBody |
핵심을 다시 정리하면:
- @RestController: 데이터(JSON)만 준다. 화면은 프론트엔드가 만든다.
- @Controller: 완성된 화면(HTML)을 준다. 서버에서 템플릿으로 화면을 만든다.
- 뷰 리졸버: 컨트롤러가 반환한 문자열을 실제 템플릿 파일로 연결해주는 역할
- 정적 리소스: CSS, JS, 이미지 등 가공 없이 그대로 전달되는 파일들
처음에는 @RestController와 @Controller의 차이가 헷갈릴 수 있지만, "서버가 뭘 돌려주는가"라는 관점에서 생각하면 명확해집니다. 데이터만 주면 @RestController, 화면까지 만들어서 주면 @Controller입니다.
'프레임워크 > 스프링' 카테고리의 다른 글
| Spring 예외 처리 전략: @ControllerAdvice와 @ExceptionHandler 활용법 (0) | 2026.01.04 |
|---|---|
| JPA N+1 문제: 개발자를 가장 괴롭히는 성능 이슈와 해결책 (0) | 2025.12.28 |
| JPA 영속성 컨텍스트(Persistence Context): 1차 캐시와 쓰기 지연이 주는 이점 (0) | 2025.12.28 |
| @Transactional의 동작 원리: 트랜잭션은 어떻게 시작되고 롤백될까? (0) | 2025.12.26 |
| AOP(관점 지향 프로그래밍): 어떻게 메소드 실행 전후에 로그를 남길까? (0) | 2025.12.24 |