프레임워크/스프링

@RestController vs @Controller: API 서버와 화면 서버의 차이

eodevelop 2025. 12. 30. 22:27
반응형

Spring Boot를 배우다 보면 @Controller@RestController 두 가지 어노테이션을 만나게 됩니다. 둘 다 컨트롤러인데 뭐가 다른 걸까요? 그냥 @RestController만 쓰면 되는 거 아닌가요?

처음엔 저도 그렇게 생각했습니다. 하지만 이 둘의 차이를 이해하면, 서버가 클라이언트에게 무엇을 돌려주는지에 대한 근본적인 개념이 잡히게 됩니다. 오늘은 최대한 쉽게 이 차이를 설명해보겠습니다.


먼저, 서버가 할 수 있는 두 가지 일

웹 서버는 크게 두 가지 종류의 응답을 할 수 있습니다:

  1. 데이터만 주는 서버 (API 서버)
  2. 화면(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.cssstatic/css/style.css 파일 반환
  • /images/logo.pngstatic/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입니다.

반응형