뷰·리다이렉트·응답 본문
컨트롤러 반환값이 뷰 해석·응답 본문 변환·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 | 조회만 수행되어 등록 횟수가 그대로 남습니다. |
그림의 위쪽 네 칸은 이 글이 고른 네 가지 반환 방식을 비교합니다. 일반 @Controller의 String과 모델은 Thymeleaf HTML로, @ResponseBody 문자열과 @RestController의 DTO는 메시지 컨버터가 HTTP 본문으로 씁니다. HttpEntity는 헤더와 본문을, ResponseEntity는 상태까지 명시하고, redirect: 접두사는 302, 직접 만든 RedirectView와 등록 응답은 303입니다. 아래쪽은 등록 POST가 303과 Location을 돌려준 뒤, 테스트의 클라이언트가 Location으로 별도의 GET을 보내 조회와 HTML 렌더링만 하는 순서를 보여 줍니다.
코드로 확인해 보면 String이라는 Java 타입만 보고는 응답의 의미를 단정할 수 없다는 것이 드러납니다. 일반 @Controller의 String은 논리 뷰 이름일 수 있지만, @ResponseBody가 붙은 메서드의 String은 메시지 컨버터가 HTTP 본문에 씁니다. HttpEntity와 ResponseEntity는 헤더와 선택적인 본문을 가지며, ResponseEntity는 상태까지 명시합니다.
이 글은 성공한 핸들러의 반환 값이 어떤 뜻인지만 다룹니다. 요청 JSON의 읽기와 검증은 요청 본문·JSON 변환에서, Accept와 produces, canWrite, 컨버터 순서, CSV 응답은 HTTP 메시지 변환기에서, CRUD의 201·204·ETag는 게시판 CRUD 웹에서, 예외와 ProblemDetail은 MVC 실패 경계 진단에서 다룹니다.
실행 기준을 하나의 그래프로 정합니다
예제는 Java 25와 Gradle 9.8.2, Spring Boot 4.1.2의 BOM을 씁니다. 이 그래프가 가져오는 Spring Framework 7.0.10과 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.2')
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.2')
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() }spring-boot-starter-thymeleaf가 있어야 뒤의 post-detail 같은 논리 뷰 이름이 실제 템플릿으로 해석됩니다. -parameters는 아래 컨트롤러의 @PathVariable long id처럼 이름을 생략한 인자가 메서드 파라미터 이름으로 경로 변수를 찾게 합니다. 애플리케이션의 시작점은 컨트롤러보다 위의 패키지에 두어 웹 구성 요소를 스캔하게 합니다.
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);
}
}예제의 게시글은 반환 경로를 확인하는 데 필요한 값만 가집니다. 컴팩트 생성자는 식별자가 양수인지와 두 문자열이 null이 아닌지만 검사하므로, 이 타입으로는 /posts/0 같은 반환 경로를 만들 게시글이 생기지 않습니다.
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이 등록을 다시 하지 않았다는 사실을 호출 횟수로 확인할 수 있습니다. 두 인터페이스는 메서드가 하나뿐인 @FunctionalInterface이므로, 테스트에서 호출을 세는 대역을 람다로 만들기 쉽습니다.
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;
}
}RedirectView의 exposeModelAttributes는 기본값이 true여서, 뷰에 전달된 모델의 단순 값을 리다이렉트 URL의 쿼리로 붙입니다. 다만 Spring Framework 7.0.10의 MVC는 핸들러가 리다이렉트 뷰를 돌려주면 Model 인자로 채운 기본 모델 대신 리다이렉트용 모델을 뷰에 넘기고, RedirectAttributes를 선언하지 않았다면 그 모델은 비어 있습니다. 그래서 이 예제의 campaign은 setExposeModelAttributes(false)가 없어도 Location에 나타나지 않으며, 이 설정이 실제로 막는 것은 RedirectAttributes를 선언하고 addAttribute로 넣은 단순 값이 쿼리에 붙는 경우입니다. 실제 템플릿이 클래스 경로에 있어야 논리 뷰 이름을 골랐다는 데서 그치지 않고 최종 text/html 렌더링까지 확인할 수 있습니다. Boot의 기본 설정에서는 templates 폴더 아래 파일 이름에서 .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의 서블릿 웹 지원과 애플리케이션 컨텍스트 테스트에서 확인할 수 있습니다.
템플릿의 텍스트와 HTML 태그도 구별합니다. 위의 th:text는 텍스트의 HTML 특수 문자를 이스케이프합니다. 제목이 <b>PRG</b>여도 제목 안에 굵은 글씨 요소가 생기지 않고 태그 문자가 그대로 보입니다. 이것은 템플릿의 이 출력 위치에 대한 규칙일 뿐, 모든 입력 검증이나 접근 제어를 대신하지는 않습니다.
본문 반환은 메시지 컨버터를 거칩니다
@ResponseBody는 반환 값을 HttpMessageConverter로 응답 본문에 기록하며, @RestController는 이 본문 의미를 타입 전체에 적용합니다.
String을 돌려준다고 언제나 JSON 문자열로 한 번 더 인코딩되지는 않습니다. 이 예제의 @ResponseBody String은 문자열 컨버터가 text/plain과 호환되는 본문으로 쓰며, 결과는 정확히 ready입니다. 다만 DTO의 표현 정책을 건너뛸 수 있으므로, 구조화된 API에는 PostResponse처럼 의미가 드러나는 타입을 고릅니다. 컨버터의 자세한 선택 순서와 사용자 정의 표현은 다음 글에서 다룹니다.
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: 기본 302 | redirect: 논리 뷰 이름 String | 302 Found와 Location |
| 명시적 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.10의 UrlBasedViewResolver는 redirect:를 특별히 인식하지만, 같은 버전의 RedirectView는 호환성을 위해 기본으로 302를 씁니다. 애플리케이션이 303을 요구한다면 코드와 테스트에서 명시합니다. 리다이렉트 사이에 넘길 데이터는 모델의 임의 노출에 기대지 말고 RedirectAttributes나 플래시 속성의 범위를 의식적으로 고릅니다.
더 깊이: 302가 기본값인 이유와 303으로 바꾸는 스위치
RedirectView가 말하는 호환성은 HTTP/1.0 클라이언트와의 호환성입니다. RedirectView API 문서에 따르면 http10Compatible 플래그가 켜진 기본 동작은 HttpServletResponse.sendRedirect에 맡겨 언제나 302를 보내고, 끄면 303을 보냅니다. 문서는 303이 HTTP/1.1 클라이언트에 맞는 코드지만 HTTP/1.0 클라이언트는 이해하지 못한다고 설명합니다. 또 많은 HTTP/1.1 클라이언트가 302를 303처럼 다루지만 POST 뒤의 리다이렉트에서 303에 기대는 클라이언트도 있으므로, 그럴 때는 이 플래그를 끄라고 권합니다.
redirect: 접두사를 쓰면 RedirectView를 만드는 쪽은 뷰 리졸버이고, 리졸버에도 같은 뜻의 redirectHttp10Compatible 설정이 있습니다. Spring의 UrlBasedViewResolver가 그렇고, 이 예제처럼 Thymeleaf를 쓰는 Boot 애플리케이션에 등록되는 thymeleaf-spring6 3.1.5의 ThymeleafViewResolver도 같은 이름의 설정을 기본값 true로 두고 그 값을 RedirectView에 넘깁니다. 그래서 모든 redirect:를 303으로 바꾸고 싶다면 메서드마다 RedirectView를 만드는 대신, 그 접두사를 실제로 처리하는 리졸버의 설정을 false로 바꾸는 방법도 있습니다. 어느 리졸버가 처리하는지는 구성마다 다르므로, 바꾼 뒤에는 위 테스트처럼 실제 상태 코드를 확인합니다.
실제 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.10의 반환 값 처리기 등록 코드를 보면 이 경로들이 서로 다른 처리기 계열이라는 것을 알 수 있습니다. 컨버터의 canWrite 선택과 사용자 정의 순서는 메시지 컨버터 구성과 WebMvcConfigurer 소스를 출발점으로 다음 글에서 이어 갑니다.
정리
- 일반
@Controller의String은 논리 뷰 이름이 되어ViewResolver와 템플릿으로 가고,@ResponseBody의String은 메시지 컨버터가 본문에 그대로 씁니다. HttpEntity는 헤더와 본문을 담고 기본 200을 쓰며,ResponseEntity는 상태까지 명시하고 일반@Controller에서도 본문 경로로 처리됩니다.redirect:접두사는 기본 302이고, 303이 필요하면ResponseEntity나RedirectView에 명시합니다.- 303 뒤의 GET은 클라이언트가 보내는 별도의 요청이며, PRG는 새로 고침의 재전송만 바꿀 뿐 중복 등록을 막지 않습니다.
/로 시작하는Location은 원래 경로를 대신하므로, 접두사 아래에 배포하면 외부 경로를 반영해 URI를 만들어야 합니다.
연습 문제
1. 이어지는 요청과 중복 등록
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이라는 주장이 맞나요? GET /status/misplaced-view-name의 200이 게시글 HTML을 렌더링했다는 뜻인지도 설명하세요.
해설 보기
① 303과 Location: /posts/43, 빈 본문이며 등록 횟수는 1입니다. ② 게시글 43의 post-detail HTML을 200으로 렌더링하고, 조회 메서드는 등록 메서드를 호출하지 않으므로 등록 횟수는 1 그대로입니다.
③ 내용이 같아도 저장소의 register가 다시 실행되어 ID 44, 303과 /posts/44, 등록 횟수 2가 됩니다. 중복된 내용이나 멱등성 키를 검사하는 코드가 없기 때문입니다. ④ 여전히 게시글 43을 조회해 200과 그 HTML을 얻고, 횟수는 2로 유지됩니다. 네 요청은 하나의 저장소 상태를 이어서 씁니다.
따라서 POST 뒤의 GET이 등록을 늘리지 않는다는 사실을, 같은 POST를 다시 보내도 등록하지 않는다는 뜻으로 넓힐 수 없습니다. 잘못 붙인 @ResponseBody 메서드도 200이지만, 본문은 리터럴 post-detail이고 HTML은 없습니다. 상태뿐 아니라 미디어 타입과 본문, Location, 서비스 기록을 함께 봐야 합니다. 이 연속 요청의 결과는 코드를 따라간 예상이므로 테스트를 실행해 확인합니다.
2. 처리 경로와 리다이렉트 주소 추적하기
① 조회할 수 있는 게시글 42가 있고 문자열 컨버터가 text/plain을 쓸 수 있습니다. PostViewController의 클래스 애노테이션만 @RestController로 바꾸고 detail 메서드와 반환 문자열은 그대로 둡니다. Accept: text/plain으로 GET /posts/42를 보내면 post-detail 템플릿이 렌더링될까요? ② 브라우저가 https://example.test/board/posts로 POST를 보냈고, 서버가 303과 Location: /posts/43을 돌려줍니다. 브라우저가 따라가는 GET의 주소와, /board 안의 조회로 보내려면 써야 할 경로를 구하세요. ③ @Controller와 템플릿은 원래대로 두고 제목만 <b>PRG</b>인 게시글을 조회합니다. <h1 th:text="${post.title}">가 만든 HTML의 제목 부분과 화면에 보이는 문자열을 구하세요.
해설 보기
① 반환 문자열이 본문 처리 경로로 가므로 응답 본문은 post-detail입니다. 모델의 post가 채워졌어도 Thymeleaf의 뷰 해석을 거치지 않았으므로 게시글 HTML은 나오지 않습니다. 이 답은 String을 돌려주는 메서드에만 해당하며, 모든 반환 타입이 똑같이 동작한다고 일반화하지 않습니다.
② 완성되는 주소는 https://example.test/posts/43입니다. /로 시작하는 경로는 원래 URI의 경로를 통째로 바꾸므로 /board가 남지 않습니다. 이 배포에서는 Location: /board/posts/43을 만들어야 의도한 곳으로 갑니다. 303 자체가 경로 접두사나 중복 등록을 해결해 주지는 않습니다.
③ 제목 부분은 <h1><b>PRG</b></h1>처럼 출력됩니다. 화면에는 <b>PRG</b>라는 문자열이 그대로 보이고, h1 안에 별도의 b 요소가 생기지 않습니다. th:text의 텍스트 이스케이프와, 제목을 HTML 마크업으로 해석하는 동작을 혼동하지 않습니다.
3. 302와 303 바꿔 보기
등록 성공 응답을 redirect: 문자열로 바꾸면 테스트의 결과는 어떻게 달라질까요? 이어서 직접 만든 RedirectView에서 setExposeModelAttributes(false)를 빼면 무엇이 바뀌는지, 두 결과를 보고 무엇을 결정해야 하는지 설명하세요.
해설 보기
redirect: 문자열은 프레임워크 기본 리다이렉트 뷰를 고르므로 303을 기대하던 테스트가 302를 받아 실패합니다. 이 결과를 보고 요구 사항이 어떤 3xx든 상관없는 것인지, 정확히 303이어야 하는 것인지를 먼저 정합니다. setExposeModelAttributes(false)를 빼도 이 예제의 Location은 /posts/42 그대로입니다. 핸들러가 리다이렉트 뷰를 돌려주면 MVC가 Model 인자에 넣은 campaign을 뷰에 넘기지 않기 때문입니다. 이 설정이 차이를 만드는 것은 핸들러가 RedirectAttributes를 선언하고 addAttribute로 단순 값을 넣었을 때이며, 그때는 그 값이 쿼리 문자열에 붙습니다. 어느 쪽이든 PRG가 멱등성까지 보장한다고 설명해서는 안 됩니다.
다음 글 HTTP 메시지 변환기에서는 이 글에서 일부러 남겨 둔 Accept와 produces, canWrite, 컨버터 순서, 사용자 정의 표현, 406·415, CSV 응답을 다룹니다.