Spring Boot

Spring Boot에서 @Controller vs @RestController 제대로 구분하기 (View 렌더링과 JSON 응답 기준)

IT Lab 2026. 3. 9. 20:00

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

Controller와 RestController 반환 흐름 요약 다이어그램

반환값이 뷰로 갈지(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/messages with 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) 실전]