뷰·리다이렉트·응답 본문
컨트롤러 반환값이 뷰 해석·응답 본문 변환·303 리다이렉트 중 어느 경로로 처리되는지 실제 Boot MVC 문맥에서 검증합니다.
컨트롤러 메서드가 성공적으로 끝나도 반환값이 곧바로 HTTP 본문이 되는 것은 아닙니다. Spring MVC는 반환 타입과 애노테이션에 맞는 반환값 처리기를 선택합니다. 전체 지원 목록에는 View·ModelAndView·Model·void·비동기 타입 등도 있으며, 이 문서는 그중 자주 맞닿는 네 반환 계약인 논리 뷰 이름·메시지 컨버터가 쓰는 본문·상태와 헤더를 가진 엔티티·리다이렉트를 비교합니다.
이 문서의 네 반환 계약은 타입과 애노테이션에 따라 뷰·본문·리다이렉트 경로로 갈린다
POST로 43을 등록하고 303 Location을 응답한 뒤 클라이언트가 별도 GET을 보내도 등록 횟수 1이 유지되는 세 사건이다.
FLOWCHART · RETURN VALUE HANDLERS · VIEW / BODY / REDIRECT
반환 계약이 뷰·본문·리다이렉트의 소비자를 결정한다
이 교재의 네 계약을 실제 요청 결과로 비교합니다. 타입·애노테이션과 명시적 status 설정을 함께 읽어야 같은 String도 뷰 이름과 HTTP 본문으로 구분할 수 있습니다.
VIEW NAME + MODEL
페이지 소비자는 실제 HTML을 받는다
GET /posts/42 → 일반 @Controller가 post-detail과 model을 반환합니다. ViewResolver가 뷰를 선택하고 Thymeleaf View가 제목·본문을 text/html로 렌더링합니다.
RESPONSEBODY / RESTCONTROLLER
본문 소비자는 문자열이나 JSON을 받는다
GET /status → @ResponseBody String은 text/plain의 ready입니다. GET /api/posts/42 → DTO는 converter가 JSON으로 씁니다. 같은 String도 반환 계약에 따라 역할이 달라집니다.
HTTPENTITY / RESPONSEENTITY
헤더·본문, 필요하면 status까지 명시한다
HttpEntity<PostResponse>는 fixture에서 200과 X-Return-Contract: http-entity, JSON body를 보냅니다. ResponseEntity<Void>인 등록 응답은 303과 Location, 빈 body를 명시합니다.
REDIRECT CONTRACT
기본 302와 명시적 303을 구분한다
fixture의 redirect:/posts/42는 302입니다. 직접 RedirectView에는 SEE_OTHER와 setExposeModelAttributes(false)를 설정해 303으로 보내고 model의 campaign이 query에 나오지 않게 합니다.
두 화살표는 등록 응답과 그 뒤의 별도 조회를 연결합니다. 테스트는 자동 리다이렉트가 아니라 Location 수동 GET을 사용하며, 동일 POST의 재전송은 별도 중복 정책이 없으면 다시 등록됩니다.
@ResponseBody에서 post-detail을 반환하면 템플릿을 찾지 않고 그 문자열을 그대로 본문에 씁니다. 이 fixture는 200·text/plain과 HTML 부재를 확인합니다.
1 · POST /posts
등록 service 1회
제목·본문으로 게시물 43을 등록합니다.
2 · RESPONSEENTITY<VOID>
303 + Location: /posts/43
응답 body는 비어 있습니다. 이후 브라우저 탐색은 GET으로 이어집니다.
3 · GET /posts/43
조회와 HTML 렌더링
테스트에서 Location을 수동으로 따라가면 새 게시물의 HTML이 나오고 등록 호출 수는 1로 유지됩니다.
PRG는 성공한 POST 뒤의 탐색을 GET으로 바꾸지만 중복 억제·재시도
안전·멱등성을 보장하지 않는다. 직접 RedirectView를
사용한다면 setStatusCode(SEE_OTHER)와
setExposeModelAttributes(false)로 명시 계약을
고정한다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 PRG fixture의 세 사건·두 진행 관계를 나타낸다. POST가 게시글 43을 등록한 뒤 303과 Location을 응답한다. 테스트의 클라이언트는 Location으로 별도 GET을 수동으로 보낸다. GET은 조회와 HTML 렌더링만 하며 등록 횟수 1을 유지한다. 화살표는 이 세 사건의 선후 관계이고, 하나의 요청 안에서 컨트롤러가 자동 재호출되는 흐름이 아니다. 같은 POST의 재전송을 금지하거나 트랜잭션 커밋을 증명하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 이 문서의 네 반환 계약은 타입과 애노테이션에 따라 뷰·본문·리다이렉트 경로로 갈린다 |
| 2 | 일반 Controller의 String과 Model은 Thymeleaf HTML로, ResponseBody 문자열과 RestController DTO는 converter가 HTTP 본문으로 씁니다. HttpEntity는 headers와 body를, ResponseEntity는 status도 명시합니다. redirect 접두사 fixture는 302, 명시적 RedirectView와 등록 ResponseEntity는 303입니다. 등록 POST→303 Location→조회 GET의 PRG는 후속 탐색을 바꾸지만 중복 억제나 멱등성을 보장하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 등록 POST fixture의 POST가 게시글을 한 번 등록합니다. | 게시글 ID: 43 · 등록 횟수: 1 | 새 게시글의 등록이 완료됩니다. |
| 303 전달 POST 응답이 303과 Location을 반환합니다. | 응답: 303 · Location: 조회 URL · 등록 횟수: 1 | 조회할 다음 URL이 전달됩니다. |
| 테스트가 별도 GET 클라이언트가 Location으로 수동 GET을 보내 조회·HTML 렌더링을 합니다. | 요청: 별도 GET · 등록 횟수: 1 · 결과: HTML | 조회만 수행되어 등록 횟수가 그대로 남습니다. |
이 문서의 네 반환 계약은 타입과 애노테이션에 따라 뷰·본문·리다이렉트 경로로 갈린다
POST로 43을 등록하고 303 Location을 응답한 뒤 클라이언트가 별도 GET을 보내도 등록 횟수 1이 유지되는 세 사건이다.
FLOWCHART · RETURN VALUE HANDLERS · VIEW / BODY / REDIRECT
반환 계약이 뷰·본문·리다이렉트의 소비자를 결정한다
이 교재의 네 계약을 실제 요청 결과로 비교합니다. 타입·애노테이션과 명시적 status 설정을 함께 읽어야 같은 String도 뷰 이름과 HTTP 본문으로 구분할 수 있습니다.
VIEW NAME + MODEL
페이지 소비자는 실제 HTML을 받는다
GET /posts/42 → 일반 @Controller가 post-detail과 model을 반환합니다. ViewResolver가 뷰를 선택하고 Thymeleaf View가 제목·본문을 text/html로 렌더링합니다.
RESPONSEBODY / RESTCONTROLLER
본문 소비자는 문자열이나 JSON을 받는다
GET /status → @ResponseBody String은 text/plain의 ready입니다. GET /api/posts/42 → DTO는 converter가 JSON으로 씁니다. 같은 String도 반환 계약에 따라 역할이 달라집니다.
HTTPENTITY / RESPONSEENTITY
헤더·본문, 필요하면 status까지 명시한다
HttpEntity<PostResponse>는 fixture에서 200과 X-Return-Contract: http-entity, JSON body를 보냅니다. ResponseEntity<Void>인 등록 응답은 303과 Location, 빈 body를 명시합니다.
REDIRECT CONTRACT
기본 302와 명시적 303을 구분한다
fixture의 redirect:/posts/42는 302입니다. 직접 RedirectView에는 SEE_OTHER와 setExposeModelAttributes(false)를 설정해 303으로 보내고 model의 campaign이 query에 나오지 않게 합니다.
두 화살표는 등록 응답과 그 뒤의 별도 조회를 연결합니다. 테스트는 자동 리다이렉트가 아니라 Location 수동 GET을 사용하며, 동일 POST의 재전송은 별도 중복 정책이 없으면 다시 등록됩니다.
@ResponseBody에서 post-detail을 반환하면 템플릿을 찾지 않고 그 문자열을 그대로 본문에 씁니다. 이 fixture는 200·text/plain과 HTML 부재를 확인합니다.
1 · POST /posts
등록 service 1회
제목·본문으로 게시물 43을 등록합니다.
2 · RESPONSEENTITY<VOID>
303 + Location: /posts/43
응답 body는 비어 있습니다. 이후 브라우저 탐색은 GET으로 이어집니다.
3 · GET /posts/43
조회와 HTML 렌더링
테스트에서 Location을 수동으로 따라가면 새 게시물의 HTML이 나오고 등록 호출 수는 1로 유지됩니다.
PRG는 성공한 POST 뒤의 탐색을 GET으로 바꾸지만 중복 억제·재시도
안전·멱등성을 보장하지 않는다. 직접 RedirectView를
사용한다면 setStatusCode(SEE_OTHER)와
setExposeModelAttributes(false)로 명시 계약을
고정한다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 PRG fixture의 세 사건·두 진행 관계를 나타낸다. POST가 게시글 43을 등록한 뒤 303과 Location을 응답한다. 테스트의 클라이언트는 Location으로 별도 GET을 수동으로 보낸다. GET은 조회와 HTML 렌더링만 하며 등록 횟수 1을 유지한다. 화살표는 이 세 사건의 선후 관계이고, 하나의 요청 안에서 컨트롤러가 자동 재호출되는 흐름이 아니다. 같은 POST의 재전송을 금지하거나 트랜잭션 커밋을 증명하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 이 문서의 네 반환 계약은 타입과 애노테이션에 따라 뷰·본문·리다이렉트 경로로 갈린다 |
| 2 | 일반 Controller의 String과 Model은 Thymeleaf HTML로, ResponseBody 문자열과 RestController DTO는 converter가 HTTP 본문으로 씁니다. HttpEntity는 headers와 body를, ResponseEntity는 status도 명시합니다. redirect 접두사 fixture는 302, 명시적 RedirectView와 등록 ResponseEntity는 303입니다. 등록 POST→303 Location→조회 GET의 PRG는 후속 탐색을 바꾸지만 중복 억제나 멱등성을 보장하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 등록 POST fixture의 POST가 게시글을 한 번 등록합니다. | 게시글 ID: 43 · 등록 횟수: 1 | 새 게시글의 등록이 완료됩니다. |
| 303 전달 POST 응답이 303과 Location을 반환합니다. | 응답: 303 · Location: 조회 URL · 등록 횟수: 1 | 조회할 다음 URL이 전달됩니다. |
| 테스트가 별도 GET 클라이언트가 Location으로 수동 GET을 보내 조회·HTML 렌더링을 합니다. | 요청: 별도 GET · 등록 횟수: 1 · 결과: HTML | 조회만 수행되어 등록 횟수가 그대로 남습니다. |
그림의 질문 순서는 이 문서에서 선택한 네 계약만 비교하는 범위입니다. 이를 코드로 확인하면 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 웹 지원과 애플리케이션 컨텍스트 테스트에서 확인할 수 있습니다.
본문 반환은 메시지 컨버터 경로다
템플릿의 텍스트와 HTML 태그도 구별하세요. 위 th:text는 텍스트의 HTML 특수 문자를 이스케이프합니다. 제목이 <b>PRG</b>여도 제목 안에 굵은 글씨 요소가 생기는 것이 아니라 태그 문자가 표시됩니다. 이는 템플릿의 이 출력 위치에 대한 계약이며 모든 입력 검증·접근 제어를 대신하는 보장은 아닙니다.
@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 상태를 더한 계약입니다.
반환값을 해석하는 조건을 함께 보세요. 일반 @Controller에서도 ResponseEntity는 별도의 응답 처리 경로로 인식하므로 @ResponseBody가 없어도 상태·헤더·본문을 설정합니다. 반면 같은 String 반환 메서드를 @RestController 아래로 옮기면 post-detail은 뷰 이름이 아니라 본문 문자열입니다. 모델에 값을 넣었다는 사실만으로 이 문자열이 템플릿 이름으로 다시 해석되지는 않습니다.
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를 다시 보낼 가능성에는 멱등성 키나 업무 중복 규칙이 따로 필요합니다.
303과 후속 GET은 서로 다른 요청입니다. 서버가 303을 반환했다고 조회 컨트롤러를 내부에서 즉시 호출하는 것은 아닙니다. 클라이언트가 Location을 읽고 새 GET을 보내며, 아래 MockMvc 테스트도 이 요청을 직접 추가합니다. POST의 Model을 새 요청이 그대로 공유하는 것도 아니므로 필요한 데이터 전달은 리다이렉트 속성·플래시 속성처럼 의도적으로 설계합니다.
또한 이 예제의 Location: /posts/43은 애플리케이션이 호스트의 루트 경로에 있다는 전제입니다. 요청 주소가 https://example.test/board/posts여도 이 경로를 해석한 결과는 https://example.test/posts/43입니다. URI.resolve의 절대 경로 규칙에 따라 /board는 자동으로 붙지 않습니다. 접두사 아래 배포한다면 실제 외부 경로를 반영한 URI 생성 방식을 별도로 정해야 합니다.
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와 멱등성을 같은 보장으로 설명해서는 안 됩니다.
독립 확인
원 RecordingPostStore를 한 번 초기화한 뒤 아래 요청을 순서대로 실행합니다. 처음에는 게시글 42 하나, nextId 43, 등록 횟수 0입니다. 두 POST의 title은 PRG, content는 같은 요청으로 동일하며 원 폼 Content-Type을 사용합니다. 성공한 응답의 Location은 테스트처럼 수동 GET으로 따릅니다.
- 첫
POST /posts - 첫 Location을 따른 GET
- 같은 내용으로 두 번째
POST /posts - 첫 Location을 다시 따른 GET
각 단계의 상태·Location 또는 HTML 대상 ID와 누적 등록 횟수를 적으세요. 두 번째 POST를 보내도 등록 횟수가 1이라는 주장을 원 fixture가 뒷받침하나요? 비교를 위해 GET /status/misplaced-view-name의 200은 게시글 HTML 렌더링의 증거인지도 설명하세요.
요청 전환과 중복 등록을 나누는 풀이
1번은 303, Location /posts/43, 빈 본문이며 등록 횟수는 1입니다. 2번은 게시글 43의 post-detail HTML을 200으로 렌더링하고 등록 횟수는 그대로 1입니다. 조회 메서드는 등록 메서드를 호출하지 않습니다.
3번은 내용이 같아도 원 저장소의 register가 다시 실행되어 ID 44, 303과 /posts/44, 등록 횟수 2가 됩니다. 중복 내용이나 멱등성 키를 검사하는 코드가 없기 때문입니다. 4번은 여전히 게시글 43을 조회해 200과 그 HTML을 얻고, 횟수는 2로 유지됩니다. 같은 초기 상태를 네 번 다시 만드는 문제가 아니라, 하나의 저장소 상태가 이어지는 문제입니다.
따라서 첫 POST 뒤 GET이 등록을 늘리지 않는다는 계약을, 동일 POST가 재전송되어도 등록하지 않는다는 계약으로 확대할 수 없습니다. 잘못 둔 @ResponseBody 메서드도 200이지만 정확한 본문은 리터럴 post-detail이고 HTML은 없습니다. 상태뿐 아니라 미디어 타입·본문·Location과 서비스 기록을 함께 판단해야 합니다. 위 연속 요청은 원 코드로 계산한 예상이며, 새로운 Boot·브라우저 실행의 결과와 구분합니다.
다음 문서에서는 여기서 의도적으로 남겨 둔 Accept, produces, canWrite, 컨버터 순서, 사용자 정의 표현, 406·415, CSV·다운로드·스트리밍 경계를 다룹니다.
연습: 같은 반환값의 처리 경로와 리다이렉트 주소를 추적하기
조건 1. 조회 가능한 게시물 42가 있고 문자열 변환기가 text/plain을 쓸 수 있습니다. PostViewController의 클래스 애노테이션만 @RestController로 바꾸고 detail 메서드·반환 문자열은 그대로 둡니다. Accept: text/plain으로 GET /posts/42를 요청하면 post-detail 템플릿이 렌더링될까요?
풀이. 반환 문자열은 본문 처리 경로로 가므로 응답 본문은 post-detail입니다. Model의 post가 채워졌어도 Thymeleaf의 뷰 해석을 요청한 결과가 아니므로 게시물 HTML은 나오지 않습니다. 이 문제는 String 반환 메서드에 한정하며 모든 반환 타입의 동작이 같다고 일반화하지 않습니다.
조건 2. 브라우저가 https://example.test/board/posts로 POST를 보냈고 서버가 303과 Location: /posts/43을 반환합니다. 브라우저가 리다이렉트를 따라 GET을 보낼 때 완성되는 주소와 /board 안의 조회로 보내기 위한 경로를 구하세요.
풀이. 완성 주소는 https://example.test/posts/43입니다. /로 시작하는 경로는 원래 URI의 경로를 교체하므로 /board가 남지 않습니다. 이 배포 조건에서는 Location: /board/posts/43을 생성하면 의도한 경로가 됩니다. 경로 설계와 303의 후속 요청 의미를 함께 확인해야 하며 303 자체가 접두사나 중복 등록을 해결하지는 않습니다.
조건 3. @Controller와 템플릿은 원래대로이고 제목만 <b>PRG</b>인 게시물을 조회합니다. <h1 th:text="${post.title}">가 만든 HTML의 제목 부분과 화면에 보이는 문자열을 구하세요.
풀이. 제목 부분은 <h1><b>PRG</b></h1>처럼 출력됩니다. 화면에는 <b>PRG</b>라는 문자열이 보이고, h1 안에 별도의 b 요소가 생기지 않습니다. th:text의 텍스트 이스케이프와 게시물 제목을 HTML 마크업으로 해석하는 동작을 혼동하면 안 됩니다.