정적 콘텐츠·MVC·JSON 응답
같은 Spring Boot 서버에서 정적 파일·Thymeleaf 뷰·JSON API를 만들고 반환값이 서로 다른 응답 경로로 해석되는 조건을 확인합니다.
웹 애플리케이션은 브라우저가 요청을 보내고 서버가 응답을 돌려주는 프로그램입니다.
요청에는 URL과 HTTP 메서드가 있고, 응답에는 상태 코드·헤더·본문이 있습니다.
아직 HTTP 세부 규칙을 외울 필요는 없습니다.
지금은 GET / 요청에 서버가 HTML 또는 JSON 본문을 돌려준다는 흐름만 잡습니다.
MVC는 화면을 만드는 책임을 세 부분으로 나누는 방식입니다.
Controller는 요청을 받고, Model은 화면에 보여 줄 데이터를 담으며, View는 모델을 이용해 HTML을 만듭니다.
JSON 응답은 View 템플릿 대신 Java 객체를 JSON 문자열로 바꾸는 메시지 변환기를 사용합니다.
객체를 전송 가능한 문자열로 바꾸는 일을 직렬화라고 합니다.
브라우저에 보이는 결과가 같아도 서버 내부 경로는 다를 수 있습니다.
index.html 파일을 그대로 내보내는 일, 컨트롤러가 모델을 만든 뒤 템플릿을 렌더링하는 일, Java 객체를 JSON으로 직렬화하는 일은 서로 다른 책임입니다.
이번에는 첫 문서의 프로젝트에 세 URL을 추가합니다.
| URL | 생산자 | 응답 형식 |
|---|---|---|
/ | 정적 리소스 처리 | text/html |
/posts | MVC 컨트롤러 + Thymeleaf | text/html |
/api/system | REST 컨트롤러 + 메시지 컨버터 | application/json |
정적 시작 화면
다음 파일은 Java 코드를 한 줄도 거치지 않습니다.
<!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;charset=UTF-8
<!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.0","framework":"7.0.8"}JSON의 공백과 속성 순서는 계약으로 삼지 않습니다.
클라이언트가 의존해야 할 것은 속성 이름, 값의 타입, 상태 코드, 미디어 타입입니다.
문자열 전체를 그대로 비교하면 직렬화기 설정 변경만으로 테스트가 깨질 수 있습니다.
@RestController 응답 차이
실패를 재현해 봅니다.
PostPageController의 @Controller를 @RestController로 바꾸고 /posts를 호출하면 템플릿이 렌더링되지 않습니다.
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
posts@RestController는 @Controller와 @ResponseBody를 합친 애노테이션입니다.
반환값을 뷰 이름으로 넘기지 않고 응답 본문으로 씁니다.
반대로 REST 컨트롤러에서 @ResponseBody를 빼는 방식으로 JSON 문제를 고치려 하면 반환 레코드의 클래스 이름을 뷰로 찾는 또 다른 실패가 생깁니다.
구분 기준은 “브라우저에서 여는 URL인가”가 아닙니다.
브라우저도 JSON을 요청할 수 있고 API 클라이언트도 HTML을 받을 수 있습니다.
반환값을 누가 소비하며, 서버가 표현을 직접 렌더링할지 객체를 직렬화할지가 기준입니다.
세 응답 경로 검증
정적 파일은 클래스 경로 리소스로, REST 컨트롤러는 RestTestClient로 작게 검증할 수 있습니다.
Spring 프레임워크 7의 RestTestClient는 MockMvc에 연결해 실제 HTTP 서버 없이 Spring MVC 매핑과 메시지 컨버터를 통과합니다.
package board.web;
import org.junit.jupiter.api.Test;
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")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType("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();
}
}PostPageController의 템플릿 렌더링은 뷰 리졸버와 Thymeleaf가 필요한 통합 경계입니다.
단순히 메서드를 호출해 "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를 제거해도 레코드를 HTML로 렌더링해 주는 컨버터가 생기는 것은 아닙니다.
보통 Jackson 컨버터가 JSON을 제공하므로 협상 결과는 여전히 JSON이거나 요청 조건에 따라 406이 됩니다.
핵심은 애노테이션 하나를 외우는 것이 아니라 요청의 Accept, 핸들러의 produces, 등록된 컨버터가 함께 후보를 결정한다는 점입니다.
세 종류의 응답 경로가 분리되었습니다.
다음 문서에서는 화면에 넣을 임시 문자열 대신 게시판의 첫 업무 객체를 만들고, 잘못된 제목과 본문 길이가 저장 단계까지 도달하지 못하도록 도메인 규칙을 고정합니다.