정적 콘텐츠·MVC·JSON 응답
같은 Spring Boot 서버에서 정적 파일·Thymeleaf 뷰·JSON API를 만들고 반환값이 서로 다른 응답 경로로 해석되는 조건을 확인합니다.
웹 애플리케이션은 브라우저가 요청을 보내고 서버가 응답을 돌려주는 프로그램입니다.
요청에는 URL과 HTTP 메서드가 있고, 응답에는 상태 코드·헤더·본문이 있습니다.
아직 HTTP 세부 규칙을 외울 필요는 없습니다.
지금은 GET 요청에 서버가 HTML이나 JSON 본문을 돌려준다는 흐름만 잡습니다.
MVC는 화면을 만드는 책임을 세 부분으로 나누는 방식입니다.
Controller는 요청을 받고, Model은 화면에 보여 줄 데이터를 담으며, View는 모델을 이용해 HTML을 만듭니다.
JSON 응답은 View 템플릿 대신 Java 객체를 JSON 문자열로 바꾸는 메시지 변환기를 사용합니다.
다만 @RestController라는 애노테이션 하나만으로 JSON이나 Content-Type이 확정되는 것은 아닙니다.
반환값 타입, 요청의 Accept, 핸들러의 produces, 등록된 메시지 컨버터가 호환되는 표현을 함께 결정합니다.
객체를 전송 가능한 문자열로 바꾸는 일을 직렬화라고 합니다.
브라우저에 보이는 결과가 같아도 서버 내부 경로는 다를 수 있습니다.
index.html 파일을 그대로 내보내는 일, 컨트롤러가 모델을 만든 뒤 템플릿을 렌더링하는 일, Java 객체를 JSON으로 직렬화하는 일은 서로 다른 책임입니다.
이번에는 첫 문서의 프로젝트에 세 URL을 추가합니다.
| URL | 생산자 | 응답 형식 |
|---|---|---|
/ | 정적 리소스 처리 | text/html |
/posts | MVC 컨트롤러 + Thymeleaf | text/html |
/api/system | REST 컨트롤러 + 메시지 컨버터 | application/json |
HTTP RESPONSE PRODUCTION · THREE PATHS
같은 서버의 세 URL은 서로 다른 생산 경로를 지난다
애노테이션은 반환값을 뷰 이름으로 해석할지 응답 본문 후보로 다룰지 정합니다. REST 본문 표현은 반환값 타입, 요청의 Accept, 핸들러의 produces, 등록된 message converter가 함께 선택합니다. MVC와 정적 경로는 각각 view와 resource 처리기가 응답 형식을 정합니다.
GET / · STATIC
정적 리소스 처리
- Welcome page 선택
경쟁하는 실제
/매핑이 없고 HTML을 받을 수 있을 때 Boot 매핑이 선택됩니다. forward:index.html포워드된
/index.html을 정적 리소스 handler가 클래스패스의static/index.html로 해석합니다.- 바이트 응답
리소스의 표현을
text/html응답으로 씁니다.
GET /posts · @Controller
MVC 뷰 렌더링
- Controller
요청을 받고 화면 데이터를 준비합니다.
- Model + view name
모델 속성과
"posts"를 반환합니다. - ViewResolver
논리 이름을 뷰 후보로 해석합니다.
- Thymeleaf
templates/posts.html에 모델을 적용합니다. - HTML
렌더링된 본문을
text/html로 응답합니다.
GET /api/system · @RestController
REST 본문 변환
- Controller
SystemResponse값을 반환합니다. - 본문 반환 처리
@RestController가 값을 뷰 이름이 아닌 본문 후보로 넘깁니다. - 콘텐츠 협상
Accept와produces의 호환 표현을 좁힙니다. - MessageConverter
값 타입을 쓸 수 있는 등록 converter를 선택합니다.
- 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가 최종 미디어 타입을 결정합니다.
정적 시작 화면
다음 파일은 사용자가 작성한 컨트롤러 메서드를 거치지 않습니다.
대신 Spring MVC의 정적 리소스 처리와 welcome page 매핑을 거칩니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>게시판</title>
</head>
<body>
<h1>게시판</h1>
<p>회원들과 글을 나누는 게시판입니다.</p>
<a href="/posts">게시글 목록 열기</a>
</body>
</html>Boot의 정적 리소스 자동 설정은 클래스 경로의 /static 아래 파일을 URL에 매핑합니다.
루트의 index.html은 더 구체적으로 경쟁하는 컨트롤러 매핑이 없을 때 시작 페이지 후보가 되어 GET / 요청에 사용됩니다.
curl -i http://localhost:8080/HTTP/1.1 200
Content-Type: text/html
<!doctype html>
<html lang="ko">정적 파일은 사용자마다 다른 모델이 필요하지 않은 자산에 적합합니다.
하지만 회원 이름이나 최신 게시글을 서버에서 계산해 넣으려면 파일을 그대로 반환하는 것만으로 부족합니다.
MVC 모델과 뷰 반환
동적인 HTML 경로는 @Controller가 맡습니다.
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 뷰 리졸버가 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>curl -i "http://localhost:8080/posts?title=HTTP"<title>HTTP - 게시판</title>
<h1>HTTP</h1>
<p>게시판에 오신 것을 환영합니다.</p>템플릿의 기본 텍스트는 파일을 브라우저에서 직접 열 때 구조를 읽기 위한 대체입니다.
실제 요청에서는 모델 값으로 교체됩니다.
컨트롤러는 HTML 문자열을 조립하지 않고 어떤 뷰에 어떤 데이터를 전달할지만 결정합니다.
JSON 응답 변환
자동화 클라이언트는 HTML 태그가 아니라 안정된 데이터 계약이 필요합니다.
@RestController의 반환 객체는 HttpMessageConverter가 JSON으로 직렬화합니다.
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
) {}
}curl -i -H "Accept: application/json" http://localhost:8080/api/systemHTTP/1.1 200
Content-Type: application/json
{"application":"board","java":25,"boot":"4.1.1","framework":"7.0.9"}위 Framework patch는 Spring Boot 4.1.1 BOM이 선택한 실행 결과를 관찰한 값입니다.
클라이언트 계약은 Framework의 정확한 patch가 아니라 속성 이름과 값 타입, 상태 코드, 미디어 타입에 둡니다. 의존성 업데이트 뒤에는 실제 응답을 다시 관찰하며 patch 문자열을 고정 기대값으로 두지 않습니다.
JSON의 공백과 속성 순서는 계약으로 삼지 않습니다.
클라이언트가 의존해야 할 것은 속성 이름, 값의 타입, 상태 코드, 미디어 타입입니다.
문자열 전체를 그대로 비교하면 직렬화기 설정 변경만으로 테스트가 깨질 수 있습니다.
@RestController 응답 차이
실패를 재현해 봅니다.
PostPageController의 @Controller를 @RestController로 바꾸고 /posts를 호출하면 템플릿이 렌더링되지 않습니다.
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
posts@RestController는 @Controller와 @ResponseBody를 합친 애노테이션입니다.
반환값을 뷰 이름으로 넘기지 않고 응답 본문으로 씁니다.
반대로 SystemController를 @Controller로 바꾸고 @ResponseBody를 두지 않으면 반환 레코드는 응답 본문으로 직렬화되지 않습니다.
객체는 모델 속성으로 다뤄지고 요청 경로에서 기본 뷰 이름을 추론하는 렌더링 경계로 들어갑니다. @RestController 자체가 @Controller와 @ResponseBody의 합성이므로, 그 상태에서 @ResponseBody만 따로 “빼는” 구성은 만들 수 없습니다.
구분 기준은 “브라우저에서 여는 URL인가”가 아닙니다.
브라우저도 JSON을 요청할 수 있고 API 클라이언트도 HTML을 받을 수 있습니다.
반환값을 누가 소비하며, 서버가 표현을 직접 렌더링할지 객체를 직렬화할지가 기준입니다.
세 응답 경로 검증
이 절에서는 두 개의 좁은 경계만 자동화해 검증합니다.
정적 파일 테스트는 파일이 테스트 클래스패스에 존재하는지만 확인하고, REST 테스트는 명시적으로 바인딩한 컨트롤러의 standalone MVC 처리만 확인합니다.
Spring Framework 7의 RestTestClient는 이 예에서 독립형 MockMvc에 연결해 실제 HTTP 서버 없이 해당 컨트롤러의 매핑과 메시지 컨버터를 통과합니다.
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);
}
}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.exists()는 테스트 클래스패스에서 파일이 보이는지만 보장합니다. GET / 매핑, 200 상태, 미디어 타입, 응답 본문, 배포 산출물 패키징은 검증하지 않습니다.
standalone RestTestClient도 컴포넌트 스캔, 전역 @ControllerAdvice, Thymeleaf와 뷰 리졸버, 정적 리소스 처리, 실제 내장 서버를 올리지 않습니다.
PostPageController의 템플릿 렌더링은 뷰 리졸버와 Thymeleaf가 필요한 통합 경계입니다.
단순히 메서드를 호출해 "posts"를 받는 테스트는 모델 값은 확인할 수 있어도 실제 템플릿 파일의 존재와 렌더링은 검증하지 못합니다.
이후 웹 계층 장에서 컨텍스트를 포함한 슬라이스 테스트로 확장합니다.
따라서 현재 자동화된 보장은 정적 파일의 클래스패스 존재와 standalone REST 응답 처리 두 경계입니다. Thymeleaf 렌더링 경계는 아직 검증하지 않았다고 명시적으로 남겨 둡니다.
TEST BOUNDARY · CLAIM ONLY WHAT RAN
검증 이름보다 테스트가 실제로 부트스트랩한 경계를 본다
이 절의 자동화 테스트가 확인한 경계는 두 개뿐입니다. 정적 파일의 클래스패스 존재와, 명시적으로 바인딩한 REST 컨트롤러의 standalone MVC 처리입니다. Thymeleaf 렌더링은 통합 경계로 남겨 둡니다.
| 검증 대상 | 실제 보장 | 보장하지 않는 것 |
|---|---|---|
| 클래스패스 존재 | 정적 시작 파일이 테스트 classpath에서 존재한다. | GET / HTTP 매핑, 정적 리소스 자동 설정, 상태 코드, 헤더, 본문 전송, 배포 산출물 패키징은 검증하지 않는다. |
| 독립형 MVC | 명시적으로 바인딩한 컨트롤러의 /api/system 매핑, 상태 코드, 응답 헤더와 JSON 본문 변환을 standalone 설정에서 확인한다. |
컴포넌트 스캔, 전역 advice, Thymeleaf와 뷰 리졸버, 정적 리소스 handler, 실제 내장 서버와 소켓을 검증하지 않는다. |
| Thymeleaf 경계 | 이 절에서는 자동화된 통합 보장이 없다. 코드와 수동 요청 절차만 제시한다. | 모델이 실제 템플릿에 적용되는지, 뷰가 렌더링되는지, 최종 HTML 응답이 생성되는지는 아직 검증하지 않는다. |
DEFERRED INTEGRATION BOUNDARY
Thymeleaf 경계는 컨텍스트를 포함한 웹 테스트에서 확인한다
후속 테스트는 실제 Spring 컨텍스트, MVC 설정, ViewResolver, 템플릿 파일을 함께 올리고 /posts의 모델과 렌더링 결과를 검증해야 합니다.
작은 테스트는 빠르고 원인이 선명하지만 보장 범위도 작습니다. 테스트가 만들지 않은 컴포넌트, 설정, 서버 경계를 통과했다고 서술하지 않습니다.
연습 문제
GET /api/system에 Accept: text/html만 보내면 어떤 상태가 되어야 하는지 먼저 예상하고 실행하세요.
그다음 produces를 제거했을 때 상태가 달라지는지 비교하고, 같다면 실패가 발생한 단계가 어떻게 달라지는지 설명합니다.
해설 보기
메서드가 produces = application/json만 선언했으므로 JSON을 허용하지 않는 Accept 요청과 일치하는 핸들러 표현이 없습니다.
정상적인 결과는 406 Not Acceptable입니다.
curl -i -H "Accept: text/html" http://localhost:8080/api/systemproduces를 제거해도 레코드를 text/html로 쓸 수 있는 컨버터가 생기는 것은 아닙니다.
같은 Accept: text/html 요청은 JSON을 허용하지 않으므로 이 구성에서는 여전히 406입니다. 명시한 produces가 있을 때에는 handler 표현 조건이 요청과 맞지 않고, 이를 제거한 뒤에는 handler가 선택되더라도 레코드를 text/html로 쓸 메시지 컨버터가 없어 본문 변환 단계에서 실패합니다. Accept: application/json 또는 */*처럼 JSON을 허용할 때에만 Jackson 컨버터가 JSON 표현을 쓸 수 있습니다.
핵심은 애노테이션 하나를 외우는 것이 아니라 요청의 Accept, 핸들러의 produces, 등록된 컨버터가 함께 후보를 결정한다는 점입니다.
세 종류의 응답 경로가 분리되었습니다.
다음 문서에서는 화면에 넣을 임시 문자열 대신 게시판의 첫 업무 객체를 만들고, 잘못된 제목과 본문 길이가 저장 단계까지 도달하지 못하도록 도메인 규칙을 고정합니다.