본문 바로가기

정적 콘텐츠·MVC·JSON 응답

같은 Spring Boot 서버에서 정적 파일·Thymeleaf 뷰·JSON API를 만들고 반환값이 서로 다른 응답 경로로 해석되는 조건을 확인합니다.

웹 애플리케이션은 브라우저가 요청을 보내면 서버가 응답을 돌려주는 프로그램입니다. 요청에는 URL과 HTTP 메서드가 있고, 응답에는 상태 코드와 헤더, 본문이 있습니다. HTTP의 자세한 규칙은 4장에서 다루므로, 지금은 GET 요청을 받은 서버가 HTML이나 JSON 본문을 돌려준다는 흐름만 잡으면 됩니다.

MVC(Model-View-Controller)는 화면을 만드는 일을 세 부분으로 나누는 방식입니다. 컨트롤러(Controller)는 요청을 받고, 모델(Model)은 화면에 보여 줄 데이터를 담으며, 뷰(View)는 모델을 이용해 HTML을 만듭니다. JSON 응답은 뷰 템플릿 대신 메시지 변환기(message converter)가 Java 객체를 JSON 문자열로 바꾸어 만듭니다. 이렇게 객체를 전송할 수 있는 문자열로 바꾸는 일을 직렬화(serialization)라고 합니다. 다만 @RestController라는 애노테이션 하나로 JSON이나 Content-Type이 정해지지는 않습니다. 반환값의 타입, 요청의 Accept 헤더, 핸들러의 produces 조건, 등록된 메시지 변환기가 함께 응답의 표현을 정합니다.

브라우저에 보이는 결과가 비슷해도 서버 안에서 응답을 만드는 경로는 다를 수 있습니다. index.html 파일을 그대로 내보내는 일, 컨트롤러가 모델을 만든 뒤 템플릿을 렌더링하는 일, Java 객체를 JSON으로 직렬화하는 일은 서로 다른 일입니다. 이번 글에서는 앞 글의 프로젝트에 다음 세 URL을 추가해 이 차이를 직접 확인합니다.

URL응답을 만드는 곳응답 형식
/정적 리소스 처리text/html
/postsMVC 컨트롤러와 Thymeleaftext/html
/api/systemREST 컨트롤러와 메시지 변환기application/json
정적 루트, MVC 게시글 화면, REST 시스템 API가 각각 리소스 처리, 뷰 렌더링, 메시지 변환을 거쳐 응답을 생산합니다. REST 본문 표현은 반환값 타입, Accept, produces, 등록된 메시지 변환기의 호환성으로 선택됩니다.

HTTP RESPONSE PRODUCTION · THREE PATHS

같은 서버의 세 URL은 서로 다른 생산 경로를 지난다

애노테이션은 반환값을 뷰 이름으로 해석할지 응답 본문 후보로 다룰지 정합니다. REST 본문 표현은 반환값 타입, 요청의 Accept, 핸들러의 produces, 등록된 message converter가 함께 선택합니다. MVC와 정적 경로는 각각 view와 resource 처리기가 응답 형식을 정합니다.

세URL의독립정상응답경로 조건을만족한세HTTP요청의정상응답경로를각각독립영역으로보인다. GET루트는welcome매핑과정적리소스바이트, GETposts는컨트롤러의모델과뷰이름·Thymeleaf렌더링, GETapi/system은JSON허용Accept와produces조건이맞는매핑뒤SystemResponse와호환컨버터가JSON을만든다. 세영역사이에실행선은없다. JSON조건실패의406과테스트보장범위는원본문설명을따른다. 정적 경로 MVC 경로 REST 경로 GET / HTML을 받는 요청 정적 리소스 처리 welcome → index.html static 파일 읽기 정적 HTML text/html · 파일 바이트 GET /posts title 요청값 Controller · Model 모델 + 뷰 이름 posts Thymeleaf 뷰 템플릿에 모델 적용 text/html 응답 GET /api/system Accept: JSON 허용 SystemController produces 조건 일치 SystemResponse 값 본문 변환 호환 converter 선택 JSON 응답
세URL의서로다른응답생산자 조건을만족한세HTTP요청의정상응답경로를각각독립영역으로보인다. GET루트는welcome매핑과정적리소스바이트, GETposts는컨트롤러의모델과뷰이름·Thymeleaf렌더링, GETapi/system은JSON허용Accept와produces조건이맞는매핑뒤SystemResponse와호환컨버터가JSON을만든다. 세영역사이에실행선은없다. JSON조건실패의406과테스트보장범위는원본문설명을따른다. 정적 경로 MVC 경로 REST 경로 GET / HTML을 받는 요청 정적 리소스 처리 welcome → index.html static 파일 읽기 정적 HTML text/html · 파일 바이트 GET /posts title 요청값 Controller · Model 모델 + 뷰 이름 posts Thymeleaf 뷰 템플릿에 모델 적용 text/html 응답 GET /api/system Accept: JSON 허용 SystemController produces 조건 일치 SystemResponse 값 본문 변환 호환 converter 선택 JSON 응답

GET / · STATIC

정적 리소스 처리

  1. Welcome page 선택

    경쟁하는 실제 / 매핑이 없고 HTML을 받을 수 있을 때 Boot 매핑이 선택됩니다.

  2. forward:index.html

    포워드된 /index.html을 정적 리소스 handler가 클래스패스의 static/index.html로 해석합니다.

  3. 바이트 응답

    리소스의 표현을 text/html 응답으로 씁니다.

GET /posts · @Controller

MVC 뷰 렌더링

  1. Controller

    요청을 받고 화면 데이터를 준비합니다.

  2. Model + view name

    모델 속성과 "posts"를 반환합니다.

  3. ViewResolver

    논리 이름을 뷰 후보로 해석합니다.

  4. Thymeleaf

    templates/posts.html에 모델을 적용합니다.

  5. HTML

    렌더링된 본문을 text/html로 응답합니다.

GET /api/system · @RestController

REST 본문 변환

  1. Controller

    SystemResponse 값을 반환합니다.

  2. 본문 반환 처리

    @RestController가 값을 뷰 이름이 아닌 본문 후보로 넘깁니다.

  3. 콘텐츠 협상

    Accept와 produces의 호환 표현을 좁힙니다.

  4. MessageConverter

    값 타입을 쓸 수 있는 등록 converter를 선택합니다.

  5. JSON

    Jackson converter가 본문을 쓰고 application/json을 설정합니다.

RETURN HANDLING ≠ MEDIA TYPE BY ANNOTATION

반환 처리와 표현 선택은 서로 다른 결정이다

@Controller 메서드의 String은 @ResponseBody가 없다면 보통 뷰 이름입니다. @RestController는 반환값을 본문 후보로 만들지만, 그것만으로 JSON이나 Content-Type이 확정되지는 않습니다.

REST 표현 선택 입력: 반환값 타입 × Accept × produces × 등록된 message converter

  • 독립된 응답 생산 경로
  • Model과 View를 분리하는 MVC 상세 경로

정적 파일, 서버 렌더링 HTML, 직렬화된 JSON은 모두 HTTP 응답이지만 생산자가 다릅니다. 특히 @RestController는 본문 처리 방식을 선택할 뿐이며, REST 경로에서는 협상 가능한 표현과 실제 converter가 최종 미디어 타입을 결정합니다.

그림은 세 요청이 서로 만나지 않는 독립된 경로로 응답을 만든다는 것을 보여 줍니다. GET /은 시작 페이지(welcome page) 매핑을 거쳐 static/index.html 파일의 바이트를 그대로 보내고, GET /posts는 컨트롤러가 모델과 뷰 이름을 돌려준 뒤 Thymeleaf가 템플릿에 모델을 적용합니다. GET /api/system은 요청의 Accept와 핸들러의 produces가 맞을 때 선택되며, 반환된 SystemResponse 값을 Jackson 변환기가 JSON으로 씁니다. 그림 아래쪽처럼 애노테이션은 반환값을 뷰 이름으로 볼지 본문 후보로 볼지를 정할 뿐이고, 최종 미디어 타입은 콘텐츠 협상과 변환기가 정합니다.


정적 시작 화면

다음 파일은 우리가 작성한 컨트롤러 메서드를 거치지 않고, Spring MVC의 정적 리소스 처리와 시작 페이지 매핑으로 응답됩니다. 파일 안의 링크 /posts는 다음 절에서 만들 컨트롤러 경로를 가리키므로, 그 컨트롤러를 만들기 전에 누르면 처리할 곳이 없어 오류 페이지가 나옵니다.

src/main/resources/static/index.html
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <title>게시판</title>
  </head>
  <body>
    <h1>게시판</h1>
    <p>회원들과 글을 나누는 게시판입니다.</p>
    <a href="/posts">게시글 목록 열기</a>
  </body>
</html>

Spring Boot의 정적 리소스 자동 설정은 클래스 경로의 /static 아래 파일을 같은 이름의 URL에 연결합니다. 그 루트에 있는 index.html은 /를 처리하는 컨트롤러 매핑이 따로 없고 요청이 HTML을 받을 수 있을 때 시작 페이지가 되어, GET / 요청이 이 파일로 포워드됩니다.

정적 응답 확인
curl -i http://localhost:8080/
응답 일부
HTTP/1.1 200
Content-Type: text/html

<!doctype html>
<html lang="ko">

Tomcat은 상태 줄에 OK 같은 이유 문구를 붙이지 않으므로 상태 줄이 HTTP/1.1 200으로 끝납니다. 실제 응답에는 Last-Modified, Content-Length 같은 헤더도 더 붙습니다.

정적 파일은 누가 요청해도 내용이 같은 자산에 알맞습니다. 회원 이름이나 최신 게시글처럼 서버에서 계산한 값을 넣어야 한다면 파일을 그대로 돌려주는 것만으로는 부족합니다.


MVC 모델과 뷰 반환

동적인 HTML은 @Controller가 붙은 클래스가 맡습니다. 아래 메서드에서는 @GetMapping("/posts")가 처리할 요청 경로를, @RequestParam이 쿼리 문자열의 title 값을, Model이 템플릿에 넘길 데이터를 맡습니다. 요청에 title이 없으면 defaultValue인 첫 게시글이 들어갑니다.

src/main/java/board/web/PostPageController.java
package board.web;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public final class PostPageController {
    @GetMapping("/posts")
    public String posts(
            @RequestParam(defaultValue = "첫 게시글") String title,
            Model model
    ) {
        model.addAttribute("title", title);
        model.addAttribute("message", "게시판에 오신 것을 환영합니다.");
        return "posts";
    }
}

여기서 돌려준 "posts"는 응답 본문이 아니라 뷰의 이름입니다. Thymeleaf의 뷰 리졸버(view resolver)가 이 이름으로 templates/posts.html을 찾아 모델의 값을 적용합니다.

src/main/resources/templates/posts.html
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="utf-8" />
    <title th:text="|${title} - 게시판|">게시판</title>
  </head>
  <body>
    <h1 th:text="${title}">제목</h1>
    <p th:text="${message}">안내</p>
  </body>
</html>
MVC 응답 확인
curl -i "http://localhost:8080/posts?title=HTTP"
렌더링된 본문 일부
<title>HTTP - 게시판</title>
<h1>HTTP</h1>
<p>게시판에 오신 것을 환영합니다.</p>

th:text가 붙은 태그의 원래 텍스트는 템플릿 파일을 브라우저에서 바로 열었을 때 구조를 알아보기 위한 자리 표시이며, 실제 요청에서는 모델의 값으로 바뀝니다. 이처럼 컨트롤러는 HTML 문자열을 직접 조립하지 않고, 어떤 뷰에 어떤 데이터를 넘길지만 정합니다.


JSON 응답 변환

다른 프로그램이 호출하는 API는 HTML 태그가 아니라 형식이 일정한 데이터를 돌려주어야 합니다. @RestController가 붙은 클래스의 메서드가 객체를 돌려주면, HttpMessageConverter가 그 객체를 JSON으로 직렬화합니다.

src/main/java/board/web/SystemController.java
package board.web;

import board.support.RuntimeVersions;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public final class SystemController {
    @GetMapping(
            value = "/api/system",
            produces = MediaType.APPLICATION_JSON_VALUE)
    public SystemResponse system() {
        var versions = RuntimeVersions.current();
        return new SystemResponse(
                "board",
                versions.javaFeature(),
                versions.boot(),
                versions.framework());
    }

    public record SystemResponse(
            String application,
            int java,
            String boot,
            String framework
    ) {}
}
JSON 응답 확인
curl -i -H "Accept: application/json" http://localhost:8080/api/system
Spring Boot 4.1.2 BOM으로 실행했을 때의 응답
HTTP/1.1 200
Content-Type: application/json

{"application":"board","java":25,"boot":"4.1.2","framework":"7.0.10"}

framework 값의 7.0.10은 Spring Boot 4.1.2의 BOM이 고른 Spring Framework의 버전입니다. Boot를 올리면 이 패치 번호도 바뀌므로, 이 API를 쓰는 쪽이 의존해야 할 것은 정확한 패치 문자열이 아니라 속성의 이름과 값의 타입, 상태 코드, 미디어 타입입니다. 같은 이유로 JSON의 공백이나 속성 순서에도 의존하지 않습니다. 응답 문자열 전체를 그대로 비교하는 테스트는 직렬화 설정이 조금만 바뀌어도 깨집니다.


@RestController 응답 차이

이번에는 일부러 잘못 만들어 봅니다. PostPageController의 @Controller를 @RestController로 바꾸고 /posts를 호출하면 템플릿이 렌더링되지 않고 다음 응답이 옵니다.

의도와 다른 응답
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8

posts

@RestController는 @Controller와 @ResponseBody를 합친 애노테이션이어서, 반환값을 뷰 이름으로 넘기지 않고 응답 본문으로 씁니다. 반환값이 String이므로 문자열 변환기가 posts라는 글자를 text/plain 본문으로 썼습니다.

반대로 SystemController의 @RestController를 @Controller로 바꾸면 @ResponseBody가 없어지므로 반환한 레코드는 본문으로 직렬화되지 않습니다. 이때 레코드는 모델 속성으로 다루어지고, Spring MVC는 요청 경로에서 기본 뷰 이름을 추론해 그 이름의 템플릿을 찾으러 갑니다. @RestController는 두 애노테이션을 합친 것이므로 @RestController를 그대로 둔 채 @ResponseBody만 따로 빼는 방법은 없습니다.

둘 중 무엇을 쓸지는 브라우저에서 여는 URL인지로 정하지 않습니다. 브라우저도 JSON을 요청할 수 있고, API 클라이언트도 HTML을 받을 수 있기 때문입니다. 서버가 표현을 직접 렌더링할지, 객체를 직렬화해 소비하는 쪽에 넘길지가 선택의 기준입니다.


세 응답 경로 검증

이 절에서는 범위가 좁은 두 가지만 자동 테스트로 확인합니다. 정적 파일 테스트는 파일이 테스트 클래스 경로에 있는지만 확인하고, REST 테스트는 직접 지정한 컨트롤러 하나를 독립형(standalone) MVC 설정으로 처리해 봅니다. Spring Framework 7에 새로 들어온 RestTestClient를 독립형 MockMvc에 연결하면, 실제 HTTP 서버 없이 해당 컨트롤러의 매핑과 메시지 변환기를 거친 응답을 검사할 수 있습니다.

src/test/java/board/web/SystemControllerTest.java
package board.web;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;

class SystemControllerTest {
    private final RestTestClient client =
            RestTestClient.bindToController(new SystemController()).build();

    @Test
    void 시스템_정보를_JSON으로_반환한다() {
        client.get()
                .uri("/api/system")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .expectBody()
                .jsonPath("$.application").isEqualTo("board")
                .jsonPath("$.java").isEqualTo(25);
    }
}
src/test/java/board/web/StaticResourceTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;

class StaticResourceTest {
    @Test
    void 시작_화면이_classpath에_존재한다() {
        var index = new ClassPathResource("static/index.html");

        assertThat(index.exists()).isTrue();
    }
}
ClassPathResource 존재 검사와 standalone RestTestClient가 실제로 검증하는 좁은 경계, 그리고 HTTP 정적 매핑, 컴포넌트 스캔, 전역 advice, Thymeleaf, 실제 서버처럼 각 테스트가 보장하지 않는 통합 경계를 비교합니다.

TEST BOUNDARY · CLAIM ONLY WHAT RAN

검증 이름보다 테스트가 실제로 부트스트랩한 경계를 본다

이 절의 자동화 테스트가 확인한 경계는 두 개뿐입니다. 정적 파일의 클래스패스 존재와, 명시적으로 바인딩한 REST 컨트롤러의 standalone MVC 처리입니다. Thymeleaf 렌더링은 통합 경계로 남겨 둡니다.

각 검증이 실제로 보장하는 범위
검증 대상 실제 보장 보장하지 않는 것
클래스패스 존재 정적 시작 파일이 테스트 classpath에서 존재한다. GET / HTTP 매핑, 정적 리소스 자동 설정, 상태 코드, 헤더, 본문 전송, 배포 산출물 패키징은 검증하지 않는다.
독립형 MVC 명시적으로 바인딩한 컨트롤러의 /api/system 매핑, 상태 코드, 응답 헤더와 JSON 본문 변환을 standalone 설정에서 확인한다. 컴포넌트 스캔, 전역 advice, Thymeleaf와 뷰 리졸버, 정적 리소스 handler, 실제 내장 서버와 소켓을 검증하지 않는다.
Thymeleaf 경계 이 절에서는 자동화된 통합 보장이 없다. 코드와 수동 요청 절차만 제시한다. 모델이 실제 템플릿에 적용되는지, 뷰가 렌더링되는지, 최종 HTML 응답이 생성되는지는 아직 검증하지 않는다.

각 검증이 실제로 보장하는 범위

클래스패스 존재

실제 보장: 정적 시작 파일이 테스트 classpath에서 존재한다.

보장하지 않는 것: GET / HTTP 매핑, 정적 리소스 자동 설정, 상태 코드, 헤더, 본문 전송, 배포 산출물 패키징은 검증하지 않는다.

독립형 MVC

실제 보장: 명시적으로 바인딩한 컨트롤러의 /api/system 매핑, 상태 코드, 응답 헤더와 JSON 본문 변환을 standalone 설정에서 확인한다.

보장하지 않는 것: 컴포넌트 스캔, 전역 advice, Thymeleaf와 뷰 리졸버, 정적 리소스 handler, 실제 내장 서버와 소켓을 검증하지 않는다.

Thymeleaf 경계

실제 보장: 이 절에서는 자동화된 통합 보장이 없다. 코드와 수동 요청 절차만 제시한다.

보장하지 않는 것: 모델이 실제 템플릿에 적용되는지, 뷰가 렌더링되는지, 최종 HTML 응답이 생성되는지는 아직 검증하지 않는다.

DEFERRED INTEGRATION BOUNDARY

Thymeleaf 경계는 컨텍스트를 포함한 웹 테스트에서 확인한다

후속 테스트는 실제 Spring 컨텍스트, MVC 설정, ViewResolver, 템플릿 파일을 함께 올리고 /posts의 모델과 렌더링 결과를 검증해야 합니다.

작은 테스트는 빠르고 원인이 선명하지만 보장 범위도 작습니다. 테스트가 만들지 않은 컴포넌트, 설정, 서버 경계를 통과했다고 서술하지 않습니다.

그림의 표처럼 두 테스트가 확인하는 범위는 이름보다 훨씬 좁습니다. ClassPathResource.exists()는 테스트 클래스 경로에서 파일이 보이는지만 확인하며, GET / 매핑과 200 상태, 미디어 타입, 응답 본문, 배포할 JAR에 파일이 들어가는지는 확인하지 않습니다. 독립형 RestTestClient도 컴포넌트 스캔, 전역 @ControllerAdvice, Thymeleaf와 뷰 리졸버, 정적 리소스 처리, 실제 내장 서버를 띄우지 않습니다.

PostPageController의 템플릿 렌더링은 뷰 리졸버와 Thymeleaf가 함께 있어야 확인할 수 있습니다. 메서드를 직접 호출해 "posts"를 받는 테스트는 모델 값은 확인할 수 있지만, 템플릿 파일이 실제로 있는지와 렌더링 결과는 확인하지 못합니다. 그래서 이 글에서 자동으로 확인한 것은 정적 파일이 클래스 경로에 있다는 것과 독립형 REST 응답 처리, 이 두 가지뿐이고, Thymeleaf 렌더링은 아직 확인하지 않았다고 남겨 둡니다. 이 부분은 이후 웹 계층을 다루는 장에서 Spring 컨텍스트를 함께 띄우는 테스트로 확인합니다. 작은 테스트는 빠르고 실패 원인이 분명하지만, 테스트가 띄우지 않은 구성 요소까지 통과했다고 말할 수는 없습니다.


정리

  • 정적 파일, 서버에서 렌더링한 HTML, 직렬화한 JSON은 모두 HTTP 응답이지만 응답을 만드는 경로가 다릅니다.
  • @Controller 메서드가 돌려준 String은 @ResponseBody가 없으면 뷰 이름이 되고, @RestController의 반환값은 응답 본문이 됩니다.
  • JSON 응답의 최종 형식은 반환값 타입, 요청의 Accept, 핸들러의 produces, 등록된 메시지 변환기가 함께 정합니다.
  • API를 쓰는 쪽은 속성 이름과 값의 타입, 상태 코드, 미디어 타입에 의존하고, 패치 버전 문자열이나 JSON의 공백, 속성 순서에는 의존하지 않습니다.
  • 테스트는 실제로 띄운 범위만 확인하므로, 독립형 MVC 테스트를 통과했다고 Thymeleaf 렌더링까지 확인된 것은 아닙니다.

연습 문제

1. JSON을 받지 않는 요청

GET /api/system에 Accept: text/html만 보내면 어떤 상태 코드가 나올지 먼저 예상하세요. 그다음 produces를 지웠을 때 상태 코드가 달라지는지, 같다면 실패하는 단계가 어떻게 달라지는지 설명하세요.

해설 보기
curl -i -H "Accept: text/html" http://localhost:8080/api/system

메서드가 produces = application/json만 선언했으므로, JSON을 받지 않는 요청과 맞는 핸들러가 없습니다. 결과는 406 Not Acceptable입니다.

produces를 지워도 레코드를 text/html로 쓸 수 있는 변환기가 새로 생기지는 않으므로, 같은 요청은 여전히 406입니다. 다만 실패하는 단계가 다릅니다. produces가 있을 때는 핸들러를 고르는 단계에서 조건이 맞지 않아 실패하고, 지운 뒤에는 핸들러가 선택되어 실행되지만 레코드를 text/html로 쓸 변환기가 없어 본문을 쓰는 단계에서 실패합니다. Accept: application/json이나 */*처럼 JSON을 받는 요청일 때만 Jackson 변환기가 JSON 본문을 씁니다.

2. 두 테스트가 모두 통과했다면

StaticResourceTest와 SystemControllerTest가 모두 통과했지만, 브라우저에서 /posts를 열자 오류 화면이 나왔습니다. 두 테스트가 이 오류를 잡지 못한 이유를 설명하세요.

해설 보기

두 테스트 모두 /posts의 경로를 지나지 않기 때문입니다. StaticResourceTest는 static/index.html 파일이 있는지만 보고, SystemControllerTest는 SystemController 하나만 독립형 설정으로 띄웁니다. PostPageController와 뷰 리졸버, templates/posts.html을 함께 띄운 테스트가 없으므로, 템플릿 파일 이름이 틀렸거나 템플릿에 문법 오류가 있어도 두 테스트는 통과합니다.

이제 세 응답 경로를 나누어 보았습니다. 다음 글 게시판 도메인 모델에서는 화면에 넣던 임시 문자열 대신 게시판의 첫 업무 객체를 만들고, 잘못된 제목과 본문 길이가 저장 단계까지 가지 못하도록 도메인 규칙을 정합니다.