뷰·리다이렉트·응답 본문
컨트롤러 반환값이 뷰 해석·응답 본문 변환·303 리다이렉트 중 어느 경로로 처리되는지 실제 Boot MVC 문맥에서 검증합니다.
컨트롤러 메서드가 성공적으로 끝나도 반환값이 곧바로 HTTP 본문이 되는 것은 아닙니다. Spring MVC는 반환 타입과 애노테이션에 맞는 반환값 처리기를 선택합니다. 전체 지원 목록에는 View·ModelAndView·Model·void·비동기 타입 등도 있으며, 이 문서는 그중 자주 맞닿는 네 반환 계약인 논리 뷰 이름·메시지 컨버터가 쓰는 본문·상태와 헤더를 가진 엔티티·리다이렉트를 비교합니다.
FLOWCHART · RETURN VALUE HANDLERS · VIEW / BODY / REDIRECT
이 문서의 네 반환 계약은 타입과 애노테이션에 따라 뷰·본문·리다이렉트 경로로 갈린다
이 문서에서 고른 네 handler 반환 계약은 소비자가 원하는 다음 행동에 따라 서버 렌더링, HTTP body, 다른 URI로의 탐색으로 갈린다.
-
DECISION 1 · ENTITY CONTRACT
HttpEntity 또는 ResponseEntity인가?
예 → headers와 optional body를 내보냅니다.
ResponseEntity는 status도 제공하고, body는 여전히 converter가 씁니다.아니오 → 두 번째 반환 계약을 확인합니다.
-
DECISION 2 · BODY CONTRACT
ResponseBody method 또는 RestController type인가?
예 →
HttpMessageConverter가 HTTP body를 씁니다.아니오 → String view name의 redirect prefix를 확인합니다.
-
DECISION 3 · STRING VIEW CONTRACT
String view name이
redirect:로 시작하는가?예 →
Location과 3xx를 반환합니다.redirect:는 기본 302이고, PRG 계약은 303을 명시합니다.직접
RedirectView를 쓰면SEE_OTHER와 model query 노출 금지를 명시할 수 있습니다.아니오 →
ViewResolver가 Model과 logical view를text/html로 렌더링합니다.
PRG는 성공한 POST 뒤의 탐색을 GET으로 바꾸지만 중복 억제·재시도
안전·멱등성을 보장하지 않는다. 직접 RedirectView를
사용한다면 setStatusCode(SEE_OTHER)와
setExposeModelAttributes(false)로 명시 계약을
고정한다.
그림의 질문 순서는 이 문서에서 선택한 네 계약만 비교하는 범위입니다. 이를 코드로 확인하면 String이라는 Java 타입만 보고 응답 의미를 단정할 수 없는 이유가 드러납니다. 일반 @Controller의 String은 논리 뷰 이름일 수 있지만, @ResponseBody가 붙은 메서드의 String은 메시지 컨버터가 HTTP 본문에 씁니다. HttpEntity와 ResponseEntity는 헤더와 선택적 본문을 소유하고, ResponseEntity는 상태까지 명시합니다.
이 문서는 성공한 핸들러의 반환 의미만 소유합니다. 요청 JSON 읽기와 검증은 ch6-4, Accept·produces·canWrite·컨버터 순서·CSV·다운로드·스트리밍은 ch6-6, CRUD의 201·204·ETag는 ch6-7, 예외와 ProblemDetail은 ch6-8의 범위입니다.
실행 기준을 하나의 그래프로 고정한다
예제는 Java 25, Gradle 9.5.1, Spring Boot 4.1.1의 BOM을 사용합니다. 이 그래프가 가져오는 Spring Framework 7.0.9, Thymeleaf 3.1.5, JUnit 6.0.3으로 실제 Boot MVC 컨텍스트와 실제 Thymeleaf 템플릿을 실행합니다.
rootProject.name = 'mvc-return-contracts'plugins { id 'java' }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
repositories { mavenCentral() }
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 25
options.compilerArgs += ['-parameters']
}
tasks.named('test') { useJUnitPlatform() }애플리케이션 시작점은 컨트롤러보다 위 패키지에 두어 웹 구성 요소를 스캔합니다.
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}예제의 게시글은 반환 경로를 관찰하는 데 필요한 값만 가집니다.
package board.post;
import java.util.Objects;
public record Post(long id, String title, String content) {
public Post {
if (id < 1) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(content, "content");
}
}조회와 등록을 분리하면 후속 GET이 등록을 다시 수행하지 않았다는 사실을 호출 횟수로 검증할 수 있습니다.
package board.post;
@FunctionalInterface
public interface PostQuery {
Post findById(long id);
}package board.post;
@FunctionalInterface
public interface PostService {
Post register(String title, String content);
}논리 뷰 이름은 모델과 함께 렌더링된다
일반 @Controller에서 반환한 String은 기본적으로 논리 뷰 이름입니다. ViewResolver는 논리 이름과 로케일을 실제 View로 해석하고, 그 뷰가 모델을 렌더링합니다. 이 경로는 문자열 자체를 HTTP 본문으로 쓰는 계약이 아닙니다.
웹 모델은 도메인 객체를 템플릿에 그대로 노출하지 않고 화면에 필요한 필드만 고정합니다.
package board.web;
import board.post.Post;
public record PostPageModel(long id, String title, String content) {
public static PostPageModel from(Post post) {
return new PostPageModel(post.id(), post.title(), post.content());
}
}API 본문도 별도 표현 타입을 사용합니다. 같은 게시글을 읽어도 뷰 모델과 JSON 표현은 서로 독립적으로 바뀔 수 있습니다.
package board.web;
import board.post.Post;
public record PostResponse(long id, String title, String content) {
public static PostResponse from(Post post) {
return new PostResponse(post.id(), post.title(), post.content());
}
}detail은 모델에 post를 넣고 post-detail을 반환합니다. 같은 컨트롤러의 두 리다이렉트는 상태 정책을 의도적으로 나눕니다. redirect: 접두사는 프레임워크 기본 리다이렉트 뷰를 선택하므로 이 버전의 기본 결과인 302를 관찰합니다. 반면 직접 만든 RedirectView는 303을 명시하고 임의의 모델 속성이 쿼리 문자열로 노출되지 않게 합니다.
package board.web;
import board.post.PostQuery;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.view.RedirectView;
@Controller
@RequestMapping("/posts")
public final class PostViewController {
private final PostQuery query;
public PostViewController(PostQuery query) {
this.query = query;
}
@GetMapping("/{id}")
public String detail(@PathVariable long id, Model model) {
model.addAttribute("post", PostPageModel.from(query.findById(id)));
return "post-detail";
}
@GetMapping("/{id}/redirect-default")
public String redirectWithFrameworkDefault(@PathVariable long id) {
return "redirect:/posts/" + id;
}
@GetMapping("/{id}/redirect-explicit")
public RedirectView redirectWithExplicitContract(@PathVariable long id, Model model) {
model.addAttribute("campaign", "must-not-leak");
var redirect = new RedirectView("/posts/" + id, true);
redirect.setStatusCode(HttpStatus.SEE_OTHER);
redirect.setExposeModelAttributes(false);
return redirect;
}
}실제 템플릿이 클래스패스에 있어야 “논리 뷰 이름을 선택했다”는 단언을 넘어 최종 text/html 렌더링까지 증명할 수 있습니다.
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title th:text="${post.title} + ' | Board'">Post | Board</title>
</head>
<body>
<main>
<article th:attr="data-post-id=${post.id}">
<h1 th:text="${post.title}">Post title</h1>
<p th:text="${post.content}">Post content</p>
</article>
</main>
</body>
</html>Spring Boot는 템플릿 엔진 스타터와 클래스패스 템플릿이 있을 때 MVC 자동 구성에 Thymeleaf를 통합합니다. 따라서 아래 테스트는 수동 JSP 리졸버나 독립형 컨트롤러 픽스처를 만들지 않습니다. 이 방식의 근거는 Boot의 Servlet 웹 지원과 애플리케이션 컨텍스트 테스트에서 확인할 수 있습니다.
본문 반환은 메시지 컨버터 경로다
@ResponseBody는 반환값을 HttpMessageConverter를 통해 응답 본문에 기록합니다. @RestController는 이 본문 의미를 타입 전체에 적용합니다.
String을 반환한다고 언제나 JSON 문자열로 이중 인코딩되는 것은 아닙니다. 이 예제의 @ResponseBody String은 문자열 컨버터가 text/plain 호환 본문으로 쓰며 결과는 정확히 ready입니다. 다만 DTO 표현 정책을 우회할 수 있으므로 구조화된 API에는 PostResponse처럼 의미가 드러나는 타입을 선택합니다. 컨버터의 세부 선택 순서와 사용자 정의 표현은 ch6-6에서 다룹니다.
package board.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping("/status")
public final class PlainStatusController {
@GetMapping
@ResponseBody
public String ready() {
return "ready";
}
@GetMapping("/misplaced-view-name")
@ResponseBody
public String misplacedViewName() {
return "post-detail";
}
}두 번째 메서드는 일부러 애노테이션을 잘못 둔 함정입니다. 응답은 오류가 아니라 200과 리터럴 post-detail이므로 상태만 보면 실제 템플릿이 렌더링되지 않았다는 사실을 놓칩니다.
@RestController의 DTO와 HttpEntity<DTO>는 모두 본문 변환을 사용합니다. HttpEntity는 헤더와 본문을 묶지만 상태를 별도로 지정하지 않아 정상 처리의 기본 200을 사용합니다. ResponseEntity는 HttpEntity에 HTTP 상태를 더한 계약입니다.
package board.web;
import board.post.PostQuery;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
public final class PostQueryController {
private final PostQuery query;
public PostQueryController(PostQuery query) {
this.query = query;
}
@GetMapping("/{id}")
public PostResponse find(@PathVariable long id) {
return PostResponse.from(query.findById(id));
}
@GetMapping("/{id}/entity")
public HttpEntity<PostResponse> findAsEntity(@PathVariable long id) {
var headers = new HttpHeaders();
headers.set("X-Return-Contract", "http-entity");
return new HttpEntity<>(PostResponse.from(query.findById(id)), headers);
}
}반환 계약을 한 표로 비교하면 다음과 같습니다.
| 상황 | 반환 계약 | 관찰 경로 |
|---|---|---|
| Controller String | 논리 뷰 이름 String과 Model | ViewResolver 뒤 Thymeleaf text/html |
| ResponseBody String | @ResponseBody String | 문자열 메시지 컨버터가 본문 기록 |
| RestController DTO | DTO | 메시지 컨버터가 JSON 기록 |
| HttpEntity DTO | HttpEntity<DTO> | 기본 성공 상태에서 헤더와 변환된 본문 |
| ResponseEntity<T> | ResponseEntity<T> | 명시 상태·헤더와 선택적으로 변환된 본문 |
| redirect: default 302 | redirect: 논리 뷰 이름 String | 302 Found와 Location |
| explicit PRG 303 | ResponseEntity<Void> 또는 설정한 RedirectView | 303 See Other, Location, 빈 본문 |
성공한 POST는 303과 Location을 명시한다
폼 등록은 저장 결과의 URI를 만든 뒤 정확한 303과 Location을 반환합니다. ResponseEntity<Void>이므로 응답 본문도 비어 있습니다. ResponseEntity 빌더는 명시 상태와 Location을 함께 구성할 수 있습니다.
package board.web;
import board.post.PostService;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public final class PostFormCommandController {
private final PostService service;
public PostFormCommandController(PostService service) {
this.service = service;
}
@PostMapping(path = "/posts", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> register(
@RequestParam String title, @RequestParam String content) {
var post = service.register(title, content);
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create("/posts/" + post.id()))
.build();
}
}클라이언트는 성공한 POST 응답을 받은 뒤 Location으로 GET을 수행합니다. 이 Post-Redirect-Get 전환은 새로 고침의 기본 후속 동작을 GET으로 바꾸지만, 중복 억제·안전한 재시도·멱등성·트랜잭션 커밋을 증명하지 않습니다. 응답을 받지 못한 클라이언트가 같은 POST를 다시 보낼 가능성에는 멱등성 키나 업무 중복 규칙이 따로 필요합니다.
redirect: 접두사와 직접 만든 RedirectView도 구분해야 합니다. Framework 7.0.9의 UrlBasedViewResolver는 redirect:를 특별히 인식하지만, 같은 버전의 RedirectView 기본 호환 경로는 302입니다. 303이 애플리케이션 계약이면 코드와 테스트에서 명시합니다. 리다이렉트 사이에 전달할 데이터는 임의의 모델 노출에 기대지 말고 RedirectAttributes 또는 플래시 속성의 범위를 의식적으로 선택합니다.
실제 Boot MVC 문맥에서 11개 계약을 실행한다
@SpringBootTest와 Boot 4의 @AutoConfigureMockMvc로 전체 MVC 애플리케이션 컨텍스트를 만들고, 클래스패스의 실제 Thymeleaf 템플릿을 렌더링합니다. 테스트 저장소는 매 테스트 전에 게시글 42 하나와 등록 횟수 0으로 초기화됩니다. 따라서 각 리다이렉트 사례의 “정확히 한 번”은 다른 테스트의 호출에 기대지 않는 지역 단언입니다.
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.TEXT_HTML;
import static org.springframework.http.MediaType.TEXT_PLAIN;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import board.post.Post;
import board.post.PostQuery;
import board.post.PostService;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(ControllerReturnValueTest.TestBeans.class)
class ControllerReturnValueTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private RecordingPostStore store;
@BeforeEach
void resetStore() {
store.reset();
}
@Test
void controller_String은_정확한_논리_뷰와_모델을_선택한다() throws Exception {
mockMvc.perform(get("/posts/42"))
.andExpect(status().isOk())
.andExpect(view().name("post-detail"))
.andExpect(model().attribute(
"post",
new PostPageModel(
42L,
"반환값 계약",
"Thymeleaf가 실제 HTML을 렌더링합니다.")));
}
@Test
void 같은_GET은_실제_Thymeleaf_HTML을_렌더링한다() throws Exception {
mockMvc.perform(get("/posts/42"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(TEXT_HTML))
.andExpect(content().string(containsString("반환값 계약 | Board")))
.andExpect(content().string(containsString("data-post-id=\"42\"")))
.andExpect(content().string(
containsString("Thymeleaf가 실제 HTML을 렌더링합니다.")));
}
@Test
void ResponseBody_String은_정확한_plain_text_본문이다() throws Exception {
mockMvc.perform(get("/status"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(TEXT_PLAIN))
.andExpect(content().string("ready"));
}
@Test
void 잘못_붙인_ResponseBody는_뷰_대신_리터럴을_쓴다() throws Exception {
mockMvc.perform(get("/status/misplaced-view-name"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(TEXT_PLAIN))
.andExpect(content().string("post-detail"))
.andExpect(content().string(not(containsString("<html"))));
}
@Test
void RestController_DTO는_JSON_필드를_쓴다() throws Exception {
mockMvc.perform(get("/api/posts/42"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.title").value("반환값 계약"))
.andExpect(jsonPath("$.content")
.value("Thymeleaf가 실제 HTML을 렌더링합니다."));
}
@Test
void HttpEntity_DTO는_기본_200과_헤더와_JSON을_쓴다() throws Exception {
mockMvc.perform(get("/api/posts/42/entity"))
.andExpect(status().isOk())
.andExpect(header().string("X-Return-Contract", "http-entity"))
.andExpect(content().contentTypeCompatibleWith(APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.title").value("반환값 계약"));
}
@Test
void ResponseEntity_Void는_정확한_303_Location과_빈_본문이다() throws Exception {
mockMvc.perform(post("/posts")
.contentType(APPLICATION_FORM_URLENCODED)
.param("title", "PRG")
.param("content", "명시적인 303으로 이동합니다."))
.andExpect(status().is(303))
.andExpect(header().string(HttpHeaders.LOCATION, "/posts/43"))
.andExpect(content().string(""));
assertThat(store.registrations()).isEqualTo(1);
}
@Test
void 명시적_303_등록은_서비스를_정확히_한_번_호출한다() throws Exception {
mockMvc.perform(post("/posts")
.contentType(APPLICATION_FORM_URLENCODED)
.param("title", "한 번")
.param("content", "등록 호출을 셉니다."))
.andExpect(status().is(303));
assertThat(store.registrations()).isEqualTo(1);
assertThat(store.findById(43L))
.isEqualTo(new Post(43L, "한 번", "등록 호출을 셉니다."));
}
@Test
void Location을_따른_GET은_저장글을_렌더링하고_등록을_늘리지_않는다() throws Exception {
var registration = mockMvc.perform(post("/posts")
.contentType(APPLICATION_FORM_URLENCODED)
.param("title", "후속 GET")
.param("content", "등록 뒤 조회만 수행합니다."))
.andExpect(status().is(303))
.andExpect(header().string(HttpHeaders.LOCATION, "/posts/43"))
.andReturn();
var location = registration.getResponse().getHeader(HttpHeaders.LOCATION);
assertThat(store.registrations()).isEqualTo(1);
mockMvc.perform(get(location))
.andExpect(status().isOk())
.andExpect(view().name("post-detail"))
.andExpect(content().contentTypeCompatibleWith(TEXT_HTML))
.andExpect(content().string(containsString("후속 GET")))
.andExpect(content().string(containsString("등록 뒤 조회만 수행합니다.")));
assertThat(store.registrations()).isEqualTo(1);
}
@Test
void redirect_논리_뷰는_프레임워크_기본_302를_사용한다() throws Exception {
mockMvc.perform(get("/posts/42/redirect-default"))
.andExpect(status().is(302))
.andExpect(view().name("redirect:/posts/42"))
.andExpect(header().string(HttpHeaders.LOCATION, "/posts/42"))
.andExpect(content().string(""));
}
@Test
void RedirectView는_정확한_303이고_모델을_쿼리에_노출하지_않는다() throws Exception {
var redirect = mockMvc.perform(get("/posts/42/redirect-explicit"))
.andExpect(status().is(303))
.andExpect(header().string(HttpHeaders.LOCATION, "/posts/42"))
.andExpect(content().string(""))
.andReturn();
assertThat(redirect.getResponse().getHeader(HttpHeaders.LOCATION))
.isEqualTo("/posts/42")
.doesNotContain("campaign", "?");
}
@TestConfiguration(proxyBeanMethods = false)
static class TestBeans {
@Bean
RecordingPostStore postStore() {
return new RecordingPostStore();
}
}
static final class RecordingPostStore implements PostQuery, PostService {
private final Map<Long, Post> posts = new LinkedHashMap<>();
private long nextId;
private int registrations;
RecordingPostStore() {
reset();
}
@Override
public Post findById(long id) {
var post = posts.get(id);
if (post == null) {
throw new IllegalArgumentException("post not found: " + id);
}
return post;
}
@Override
public Post register(String title, String content) {
var post = new Post(nextId++, title, content);
posts.put(post.id(), post);
registrations++;
return post;
}
int registrations() {
return registrations;
}
void reset() {
posts.clear();
posts.put(
42L,
new Post(
42L,
"반환값 계약",
"Thymeleaf가 실제 HTML을 렌더링합니다."));
nextId = 43L;
registrations = 0;
}
}
}열한 테스트는 논리 뷰와 모델, 실제 HTML, 두 문자열 본문, DTO JSON, HttpEntity, 세 가지 리다이렉트 계약을 각각 관찰합니다. text/plain은 호환 미디어 타입과 정확한 본문만 고정하고 기본 charset 표기는 고정하지 않습니다.
반환 타입은 소비자의 다음 행동에서 고른다
브라우저가 같은 요청 안에서 HTML을 받아 그릴 때는 논리 뷰와 모델이 자연스럽습니다. 호출자가 표현 본문을 소비할 때는 @ResponseBody 또는 @RestController와 DTO를 사용합니다. 상태·헤더·선택적 본문을 함께 계약해야 하면 ResponseEntity<T>가 의도를 가장 분명하게 드러냅니다. 성공한 명령 뒤 브라우저를 조회 URI로 이동시킬 때는 303과 Location을 명시합니다.
Spring MVC의 지원 반환 타입 목록과 Framework 7.0.9의 반환값 처리기 등록 코드는 이 경로들이 서로 다른 처리기 계열임을 보여 줍니다. 컨버터의 canWrite 선택과 사용자 정의 순서는 메시지 컨버터 구성 및 WebMvcConfigurer 소스를 출발점으로 ch6-6에서 이어갑니다.
연습으로 등록 성공 응답을 redirect: 문자열로 바꿔 보세요. 테스트가 303에서 302로 바뀌는지 먼저 관찰한 뒤, 요구사항이 “어떤 3xx”인지 “정확한 303”인지 결정합니다. 이어 직접 만든 RedirectView에서 setExposeModelAttributes(false)를 제거하고 모델 값이 Location에 추가되는지 확인하세요. 결과를 보고도 PRG와 멱등성을 같은 보장으로 설명해서는 안 됩니다.
다음 문서에서는 여기서 의도적으로 남겨 둔 Accept, produces, canWrite, 컨버터 순서, 사용자 정의 표현, 406·415, CSV·다운로드·스트리밍 경계를 다룹니다.