뷰·리다이렉트·응답 본문
컨트롤러의 문자열·Model, ResponseBody, RestController, ResponseEntity, 303 리다이렉트 반환값이 ViewResolver와 메시지 컨버터 중 어느 경로로 가는지 검증합니다.
컨트롤러가 String을 반환한다고 항상 텍스트 본문이 되는 것은 아닙니다.
@Controller의 문자열은 기본적으로 논리 뷰 이름이고, @ResponseBody가 있으면 본문 텍스트입니다.
@RestController의 객체는 컨버터가 표현으로 쓰며 ResponseEntity는 상태와 헤더까지 함께 정합니다.
HTML 모델·뷰 응답
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.PathVariable;
@Controller
class PostViewController {
private final PostQuery query;
PostViewController(PostQuery query) {
this.query = query;
}
@GetMapping("/posts/{id}")
String detail(
@PathVariable long id,
Model model) {
var post = query.required(id);
model.addAttribute(
"post",
PostPageModel.from(post));
return "post-detail";
}
}post-detail은 응답 텍스트가 아니라 ViewResolver가 템플릿으로 해석할 이름입니다.
Thymeleaf 리졸버가 접두사와 접미사를 적용해 리소스를 찾고 모델을 HTML로 렌더링합니다.
논리 이름을 사용자 입력에서 받지 않습니다. 컨트롤러 코드가 허용 뷰를 선택합니다. 모델에는 엔티티 전체보다 페이지 모델을 넣어 지연 로딩과 민감 필드 노출을 막습니다.
ResponseBody 본문 응답
package board.web;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
class PlainStatusController {
@GetMapping(
path = "/api/plain-status",
produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
String status() {
return "ready";
}
}@ResponseBody를 빼면 Spring은 ready라는 뷰를 찾으려 합니다.
반대로 페이지 메서드에 실수로 붙이면 템플릿을 렌더링하지 않고 post-detail 글자만 브라우저에 표시합니다.
컨트롤러 타입과 애노테이션을 읽으면 반환값 해석을 예측할 수 있어야 합니다.
@RestController는 클래스 전체에 @Controller와 @ResponseBody 의미를 결합합니다.
JSON API와 HTML 페이지 메서드를 한 클래스에 섞기보다 상호작용 방식별로 컨트롤러를 나누면 의도하지 않은 애노테이션 적용을 줄입니다.
실패 증상도 상태만 보면 놓칠 수 있습니다.
HTML 페이지 메서드에 @ResponseBody를 잘못 붙인 실제 요청은 500이 아니라 정상처럼 보이는 200을 반환합니다.
그러나 ViewResolver는 호출되지 않고 브라우저에는 템플릿 대신 논리 이름 한 줄만 나타납니다.
expected path = ViewResolver -> post-detail template -> text/html
actual status = 200
actual Content-Type = text/plain;charset=ISO-8859-1
actual body = post-detail따라서 페이지 회귀 검증은 isOk()에서 끝내지 않고 뷰 이름·모델·포워드 URL 또는 최종 text/html을 확인합니다.
API 검증은 반대로 본문 미디어 타입과 스키마를 확인합니다.
성공 상태인데도 잘못된 처리 경로를 탄 것이 이 문서에서 구분해야 할 핵심 실패입니다.
객체의 JSON 응답
package board.web;
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")
class PostQueryController {
private final PostQuery query;
PostQueryController(PostQuery query) {
this.query = query;
}
@GetMapping("/{id}")
PostResponse get(
@PathVariable long id) {
return PostResponse.from(
query.required(id));
}
}Jackson 컨버터는 PostResponse를 JSON으로 씁니다.
컨트롤러가 ObjectMapper와 응답 스트림을 직접 잡지 않아 콘텐츠 협상과 공통 설정이 유지됩니다.
단순 객체 반환은 기본 200입니다.
생성 201, 삭제 204, 조건부 304, 리다이렉트처럼 상태와 헤더가 필요하면 ResponseEntity를 사용합니다.
모든 응답을 ResponseEntity로 감쌀 필요는 없지만 HTTP 메타데이터가 계약이면 명시합니다.
폼 성공과 303
package board.web;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
class PostFormCommandController {
private final PostService service;
PostFormCommandController(
PostService service) {
this.service = service;
}
@PostMapping("/posts")
ResponseEntity<Void> register(
@ModelAttribute CreatePostForm form) {
var saved = service.register(
form.toCommand());
return ResponseEntity
.status(HttpStatus.SEE_OTHER)
.location(URI.create(
"/posts/" + saved.id()))
.build();
}
}"redirect:/posts/42"처럼 축약된 뷰 이름도 사용할 수 있지만 기본 리다이렉트 상태와 메서드 변환 의도를 확인합니다.
303을 정확히 계약하려면 위처럼 상태를 명시하거나 상태가 설정된 RedirectView를 사용합니다.
뷰·본문 경로 검증
뷰 이름과 포워드는 MockMvc의 뷰 검증으로, 본문과 헤더는 프레임워크 7 RestTestClient로 확인합니다.
package board.web;
import static org.springframework.test.web.servlet
.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet
.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet
.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet
.result.MockMvcResultMatchers.view;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.servlet.view
.InternalResourceViewResolver;
class ControllerReturnValueTest {
@Test
void page_String은_logical_view로_해석된다()
throws Exception {
var resolver =
new InternalResourceViewResolver(
"/WEB-INF/views/", ".jsp");
var mockMvc = MockMvcBuilders
.standaloneSetup(
new PostViewController(
new FixedPostQuery(
post(42L))))
.setViewResolvers(resolver)
.build();
mockMvc.perform(get("/posts/42"))
.andExpect(view()
.name("post-detail"))
.andExpect(model()
.attributeExists("post"))
.andExpect(forwardedUrl(
"/WEB-INF/views/"
+ "post-detail.jsp"));
}
@Test
void ResponseBody_String은_plain_text다() {
var client = RestTestClient
.bindToController(
new PlainStatusController())
.build();
client.get()
.uri("/api/plain-status")
.exchange()
.expectStatus().isOk()
.expectHeader()
.contentTypeCompatibleWith("text/plain")
.expectBody(String.class)
.isEqualTo("ready");
}
}ControllerReturnValueTest
> page_String은_logical_view로_해석된다() PASSED
ControllerReturnValueTest
> ResponseBody_String은_plain_text다() PASSED
view = post-detail
plain body = ready
BUILD SUCCESSFUL반환 형식 선택
| 상황 | 반환 규칙 | 처리 경로 |
|---|---|---|
| HTML 페이지 | 뷰 이름 + Model | ViewResolver |
| 평문 상태 텍스트 | @ResponseBody String | 문자열 컨버터 |
| JSON 조회 | RestController의 DTO | JSON 컨버터 |
| API 생성 | ResponseEntity<DTO> | 상태·위치·컨버터 |
| 폼 성공 | ResponseEntity<Void> 303 | 브라우저 리다이렉트 |
| 파일 다운로드 | Resource/스트리밍 응답 | 바이너리 컨버터 |
String JSON을 직접 반환하고 콘텐츠 타입만 JSON으로 설정하면 이스케이프와 스키마 테스트가 어렵습니다.
DTO를 반환해 컨버터에 맡깁니다.
이미 직렬화된된 JSON 문자열을 다시 컨버터에 넣으면 따옴표된 JSON 문자열이 될 수 있습니다.
연습 문제
게시글 내보내기 엔드포인트를 HTML 진행 페이지, JSON 작업 상태, CSV 다운로드라는 세 상호작용으로 나누세요. 각 컨트롤러 반환 타입과 상태·콘텐츠 타입을 정하고 뷰 리졸버와 메시지 컨버터 경로가 섞이지 않는지 검증합니다.
해설 보기
페이지는 @Controller에서 "export-status"와 모델, API는 @RestController에서 ExportJobResponse, 완료 파일은 ResponseEntity<Resource>와 text/csv·Content-Disposition을 사용합니다.
return ResponseEntity.ok()
.contentType(
MediaType.parseMediaType("text/csv"))
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(
"posts.csv",
StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);파일명을 원시 헤더 문자열로 연결하지 않습니다. 테스트는 HTML 뷰 이름, JSON 스키마, CSV 바이트와 다운로드 헤더를 각각 분리합니다.
다음 문서에서는 본문 경로를 실제로 담당하는 HttpMessageConverter가 요청 Content-Type과 응답 Accept를 보고 리더·작성기를 선택하는 과정을 추적합니다.