MVC 역할 경계
다중 값 요청에서 양의 ID 하나를 검증하고, 조회·페이지 모델·논리 뷰 허용 목록을 분리한 뒤 순수 단위 테스트와 Spring standalone MockMvc 테스트로 서로 다른 계약을 검증합니다.
MVC의 목적은 클래스 세 개에 Controller, Model, View라는 이름을 붙이는 데 있지 않습니다.
입력 형식, 조회 규칙, 화면에 공개할 데이터, 논리 뷰 선택, 마크업 생성은 서로 다른 이유로 바뀝니다. 이 변경 이유를 경계로 만들면 컨트롤러는 요청에서 나온 경로나 HTML을 조립하지 않고, 템플릿은 저장소를 조회하거나 업무 규칙을 다시 계산하지 않습니다.
이 문서에서는 게시글 상세 화면을 다음 계약으로 고정합니다.
- 요청에는
id값이 정확히 하나 있어야 하고, 그 값은long범위의 양수여야 합니다. - 유효성 검사가 끝나기 전에는
PostQuery를 호출하지 않습니다. - 컨트롤러는 유효한 요청마다 쿼리를 정확히 한 번 호출합니다.
- 도메인
Post를 템플릿에 넘기지 않고, 원시 타입과String만 가진PostPageModel로 변환합니다. - 논리 뷰 이름은 상수
post-detail이며, 정확한 허용 목록에 등록된 뷰만 해석합니다. - HTML 이스케이프와 마크업 선택은 렌더링 계층의 책임으로 남깁니다.
REQUEST ADAPTER → CONTROLLER OUTPUT → REGISTERED VIEW
ModelView가 controller의 결정과 view rendering을 분리한다
controller는 입력 검증, use case 호출, page model 변환, 고정 논리 이름 선택까지 맡습니다. resolver와 내부 view는 그 출력 계약만 받아 선택된 View와 markup을 완성합니다.
01 · REQUEST BOUNDARY
adapter는 파라미터를 만들고 controller는 양의 ID를 먼저 검증한다
missing·non-numeric·non-positive ID는 PostQuery 호출
전에 실패합니다. 요청은 id 값만 제공하며 view 경로를 선택하지
않습니다.
02 · USE CASE → DOMAIN
PostQuery 결과인 Post는 page-model mapper에서 소비된다
mapper는 표시할 텍스트와 의미 플래그를 불변 record인
PostPageModel로 투영합니다. domain 객체는 template로
직접 가지 않습니다.
03 · MODELVIEW CONTRACT
controller 출력은 고정 논리 이름과 이름 붙은 typed model이다
viewName은 post-detail로 고정되고
model["post"]에는 PostPageModel이
들어갑니다. controller는 HTML을 작성하지 않습니다.
04 · EXACT VIEW RESOLUTION
exact registry가 등록된 고정 View를 골라 markup을 만든다
post-detail 키와 정확히 일치하는 등록 View만
선택합니다. path-like name과 unknown name은 실패합니다. view는
완성된 page model로 markup만 만들고 query를 호출하지 않습니다.
ModelView는 controller와 rendering 사이의 출력
계약입니다. 역할은 서로를 탐색하지 않고, controller → resolver →
view로 명시적인 값만 전달합니다.
요청에서 마크업까지의 단방향 흐름
입력 어댑터는 다중 값 파라미터를 애플리케이션이 이해하는 양의 ID 하나로 바꿉니다. 누락, 빈 값, 중복, 숫자가 아닌 값, long 범위를 넘는 값, 0과 음수는 모두 같은 입력 경계에서 거절합니다. 이 검증을 통과한 뒤에만 컨트롤러가 조회 사용 사례를 호출합니다.
조회 결과인 Post는 도메인·애플리케이션 쪽 객체입니다. 페이지 모델 매퍼는 화면에 필요한 필드만 골라 PostPageModel을 만듭니다. 내부 검토 메모 같은 값은 이 경계를 통과하지 않습니다. String 필드는 “이미 안전한 HTML”이 아니라 템플릿이 텍스트 문맥으로 렌더링해야 할 값입니다.
ModelView는 논리 뷰 이름과 속성 맵을 운반합니다. 컨트롤러가 반환하는 이름은 언제나 post-detail입니다. 요청 파라미터를 뷰 이름이나 파일 경로로 사용하지 않습니다. 마지막으로 정확한 허용 목록 레지스트리가 이 이름을 고정된 뷰 구현에 대응시킵니다. 이름에 접두사와 접미사를 붙여 임의 경로를 만드는 방식은 사용하지 않습니다.
이 흐름에는 세 가지 금지선이 있습니다.
- 요청 값으로 뷰 경로나 템플릿 파일을 선택하지 않습니다.
- 도메인 객체나 저장소 행을 템플릿에 직접 공개하지 않습니다.
- 컨트롤러가 HTML 조각을 만들거나 출력 스트림에 쓰지 않습니다.
한 파일로 실행하는 역할 계약
다음 파일은 Java 25, Spring Boot 4.1.1의 의존성 관리, Spring Framework 7.0.9, JUnit 6.0.3 조합에서 실행하는 하나의 완결된 compilation unit입니다. 필요한 컨트롤러, 입력 변환기, 쿼리 대역, 도메인·페이지 모델, 뷰 레지스트리, Spring 어댑터를 모두 중첩 타입으로 포함합니다.
순수 컨트롤러 테스트는 Servlet 요청 객체나 Spring MVC 디스패치 없이 입력·호출 횟수·결과 객체를 검증합니다. 그 아래의 standalone MockMvc 테스트는 mock Servlet 요청이 Spring MVC의 매핑, 메서드 인자 해석, 반환 값 처리, 예외 상태 매핑, 등록한 ViewResolver를 통과하는지 검증합니다. 두 테스트는 서로를 대체하지 않습니다.
package board.mvc;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
public class MvcRoleBoundaryTest {
@Test
void valid_input_is_queried_once_and_becomes_the_exact_page_contract() {
var query = new RecordingPostQuery(samplePost());
var controller = controller(query);
var result = controller.handle(
Map.of("id", List.of("42")));
var expected = expectedPageModel();
assertAll(
() -> assertEquals(1, query.invocations()),
() -> assertEquals(
List.of(42L),
query.requestedIds()),
() -> assertEquals(
"post-detail",
result.viewName()),
() -> assertEquals(
Map.of("post", expected),
result.model()),
() -> assertSame(
PostPageModel.class,
result.model().get("post").getClass()));
}
@Test
void every_invalid_id_fails_before_the_query() {
var cases = List.of(
new InputCase("missing", Map.of()),
new InputCase(
"empty list",
Map.of("id", List.of())),
new InputCase(
"duplicate",
Map.of(
"id",
List.of("41", "42"))),
new InputCase(
"blank",
Map.of("id", List.of(" "))),
new InputCase(
"nonnumeric",
Map.of("id", List.of("forty-two"))),
new InputCase(
"zero",
Map.of("id", List.of("0"))),
new InputCase(
"negative",
Map.of("id", List.of("-1"))),
new InputCase(
"overflow",
Map.of(
"id",
List.of("9223372036854775808"))));
for (var input : cases) {
var query = new RecordingPostQuery(samplePost());
var controller = controller(query);
var exception = assertThrows(
BadRequestException.class,
() -> controller.handle(
input.parameters()),
input.label());
assertAll(
input.label(),
() -> assertTrue(
exception.getMessage()
.contains("id")),
() -> assertEquals(
0,
query.invocations()));
}
}
@Test
void page_model_has_only_the_declared_primitive_and_string_shape() {
var components =
PostPageModel.class.getRecordComponents();
assertAll(
() -> assertArrayEquals(
new String[] {
"id",
"title",
"bodyText",
"contentLength",
"showLongReadBadge"
},
Arrays.stream(components)
.map(component ->
component.getName())
.toArray(String[]::new)),
() -> assertArrayEquals(
new Class<?>[] {
long.class,
String.class,
String.class,
int.class,
boolean.class
},
Arrays.stream(components)
.map(component ->
component.getType())
.toArray(Class<?>[]::new)));
}
@Test
void map_copy_of_is_an_unmodifiable_shallow_snapshot() {
var mutableValue = new ArrayList<>(
List.of("before"));
var source = new HashMap<String, Object>();
source.put("items", mutableValue);
var result = new ModelView(
"post-detail",
source);
source.put("added later", "not copied");
assertAll(
() -> assertFalse(
result.model()
.containsKey("added later")),
() -> assertThrows(
UnsupportedOperationException.class,
() -> result.model().put(
"new key",
"new value")));
mutableValue.add("after");
assertEquals(
List.of("before", "after"),
result.model().get("items"));
}
@Test
void view_registry_accepts_only_an_exact_allowed_name() {
var allowedView = new RecordingView();
var registry = ExactViewRegistry.postDetailOnly(
allowedView);
assertSame(
allowedView,
registry.resolveViewName(
"post-detail",
Locale.KOREA));
assertThrows(
UnknownViewException.class,
() -> registry.resolveViewName(
"post-list",
Locale.KOREA));
for (var pathLike : List.of(
"../secret",
"/WEB-INF/views/post-detail.jsp",
"post-detail.jsp",
"post\\detail",
"file:post-detail")) {
assertThrows(
IllegalArgumentException.class,
() -> registry.resolveViewName(
pathLike,
Locale.KOREA),
pathLike);
}
}
@Test
void standalone_mock_mvc_exposes_the_valid_model_and_view()
throws Exception {
var query = new RecordingPostQuery(samplePost());
var fixture = springFixture(query);
var expected = expectedPageModel();
fixture.mvc()
.perform(get("/posts/detail")
.queryParam("id", "42"))
.andExpect(status().isOk())
.andExpect(view().name("post-detail"))
.andExpect(model().attribute(
"post",
expected));
assertAll(
() -> assertEquals(
1,
query.invocations()),
() -> assertEquals(
List.of(42L),
query.requestedIds()),
() -> assertEquals(
1,
fixture.view().renders()),
() -> assertEquals(
expected,
fixture.view()
.lastModel()
.get("post")));
}
@Test
void standalone_mock_mvc_maps_nonnumeric_input_to_400_without_query()
throws Exception {
var query = new RecordingPostQuery(samplePost());
var fixture = springFixture(query);
var result = fixture.mvc()
.perform(get("/posts/detail")
.queryParam("id", "not-a-number"))
.andExpect(status().isBadRequest())
.andReturn();
assertAll(
() -> assertInstanceOf(
BadRequestException.class,
result.getResolvedException()),
() -> assertEquals(
0,
query.invocations()),
() -> assertEquals(
0,
fixture.view().renders()));
}
private static PostDetailPageController controller(
PostQuery query) {
return new PostDetailPageController(
query,
new PostPageModelMapper());
}
private static SpringFixture springFixture(
RecordingPostQuery query) {
var view = new RecordingView();
var registry = ExactViewRegistry.postDetailOnly(
view);
MockMvc mvc = MockMvcBuilders
.standaloneSetup(
new SpringPageAdapter(
controller(query)))
.setViewResolvers(registry)
.build();
return new SpringFixture(mvc, view);
}
private static Post samplePost() {
return new Post(
42L,
"<b>MVC boundary</b>",
"request text, not prebuilt HTML",
80,
"internal moderation note");
}
private static PostPageModel expectedPageModel() {
return new PostPageModel(
42L,
"<b>MVC boundary</b>",
"request text, not prebuilt HTML",
80,
true);
}
@FunctionalInterface
interface PageController {
ModelView handle(
Map<String, List<String>> parameters);
}
record ModelView(
String viewName,
Map<String, Object> model) {
ModelView {
viewName = Objects.requireNonNull(
viewName,
"viewName");
if (viewName.isBlank()) {
throw new IllegalArgumentException(
"viewName must not be blank");
}
model = Map.copyOf(
Objects.requireNonNull(
model,
"model"));
}
}
static final class RequiredPositiveId {
private RequiredPositiveId() {}
static long from(
Map<String, List<String>> parameters) {
Objects.requireNonNull(
parameters,
"parameters");
var values = parameters.get("id");
if (values == null || values.size() != 1) {
throw new BadRequestException(
"id must appear exactly once");
}
var raw = values.getFirst();
if (raw == null || raw.isBlank()) {
throw new BadRequestException(
"id must be a positive integer");
}
final long id;
try {
id = Long.parseLong(raw);
} catch (NumberFormatException exception) {
throw new BadRequestException(
"id must be a positive integer",
exception);
}
if (id <= 0) {
throw new BadRequestException(
"id must be positive");
}
return id;
}
}
interface PostQuery {
Post required(long id);
}
record Post(
long id,
String title,
String bodyText,
int contentLength,
String moderationNote) {
Post {
if (id <= 0) {
throw new IllegalArgumentException(
"id must be positive");
}
title = Objects.requireNonNull(
title,
"title");
bodyText = Objects.requireNonNull(
bodyText,
"bodyText");
if (contentLength < 0) {
throw new IllegalArgumentException(
"contentLength must not be negative");
}
moderationNote = Objects.requireNonNull(
moderationNote,
"moderationNote");
}
}
record PostPageModel(
long id,
String title,
String bodyText,
int contentLength,
boolean showLongReadBadge) {}
static final class PostPageModelMapper {
PostPageModel map(Post post) {
Objects.requireNonNull(post, "post");
return new PostPageModel(
post.id(),
post.title(),
post.bodyText(),
post.contentLength(),
post.contentLength() >= 60);
}
}
static final class PostDetailPageController
implements PageController {
private static final String VIEW_NAME =
"post-detail";
private final PostQuery query;
private final PostPageModelMapper mapper;
PostDetailPageController(
PostQuery query,
PostPageModelMapper mapper) {
this.query = Objects.requireNonNull(
query,
"query");
this.mapper = Objects.requireNonNull(
mapper,
"mapper");
}
@Override
public ModelView handle(
Map<String, List<String>> parameters) {
var id = RequiredPositiveId.from(parameters);
var post = query.required(id);
var page = mapper.map(post);
return new ModelView(
VIEW_NAME,
Map.of("post", page));
}
}
@Controller
static final class SpringPageAdapter {
private final PostDetailPageController delegate;
SpringPageAdapter(
PostDetailPageController delegate) {
this.delegate = Objects.requireNonNull(
delegate,
"delegate");
}
@GetMapping("/posts/detail")
String detail(
@RequestParam(
name = "id",
required = false)
List<String> idValues,
Model model) {
var parameters = idValues == null
? Map.<String, List<String>>of()
: Map.of(
"id",
List.copyOf(idValues));
var result = delegate.handle(parameters);
model.addAllAttributes(result.model());
return result.viewName();
}
}
static final class ExactViewRegistry
implements ViewResolver {
private final Map<String, View> allowed;
private ExactViewRegistry(
Map<String, View> allowed) {
Objects.requireNonNull(allowed, "allowed");
if (allowed.isEmpty()) {
throw new IllegalArgumentException(
"at least one view is required");
}
allowed.forEach((name, view) -> {
rejectPathLikeName(name);
Objects.requireNonNull(
view,
"view for " + name);
});
this.allowed = Map.copyOf(allowed);
}
static ExactViewRegistry postDetailOnly(
View postDetail) {
return new ExactViewRegistry(
Map.of(
"post-detail",
Objects.requireNonNull(
postDetail,
"postDetail")));
}
@Override
public View resolveViewName(
String viewName,
Locale locale) {
Objects.requireNonNull(locale, "locale");
rejectPathLikeName(viewName);
var resolved = allowed.get(viewName);
if (resolved == null) {
throw new UnknownViewException(viewName);
}
return resolved;
}
private static void rejectPathLikeName(
String viewName) {
if (viewName == null || viewName.isBlank()) {
throw new IllegalArgumentException(
"view name must not be blank");
}
if (viewName.contains("/")
|| viewName.contains("\\")
|| viewName.contains("..")
|| viewName.contains(":")
|| viewName.endsWith(".jsp")
|| viewName.endsWith(".html")) {
throw new IllegalArgumentException(
"path-like view name is forbidden");
}
}
}
static final class RecordingPostQuery
implements PostQuery {
private final Post answer;
private final List<Long> requestedIds =
new ArrayList<>();
RecordingPostQuery(Post answer) {
this.answer = Objects.requireNonNull(
answer,
"answer");
}
@Override
public Post required(long id) {
requestedIds.add(id);
return answer;
}
int invocations() {
return requestedIds.size();
}
List<Long> requestedIds() {
return List.copyOf(requestedIds);
}
}
static final class RecordingView implements View {
private int renders;
private Map<String, ?> lastModel = Map.of();
@Override
public String getContentType() {
return "text/plain;charset=UTF-8";
}
@Override
public void render(
Map<String, ?> model,
HttpServletRequest request,
HttpServletResponse response)
throws Exception {
renders++;
lastModel = Map.copyOf(model);
response.setContentType(getContentType());
response.getWriter().write(
"fixed test view");
}
int renders() {
return renders;
}
Map<String, ?> lastModel() {
return lastModel;
}
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
static final class BadRequestException
extends RuntimeException {
BadRequestException(String message) {
super(message);
}
BadRequestException(
String message,
Throwable cause) {
super(message, cause);
}
}
static final class UnknownViewException
extends RuntimeException {
UnknownViewException(String viewName) {
super("unknown view: " + viewName);
}
}
record InputCase(
String label,
Map<String, List<String>> parameters) {}
record SpringFixture(
MockMvc mvc,
RecordingView view) {}
}순수 테스트가 고정하는 것
첫 번째 테스트 묶음은 Servlet 요청이나 Spring MVC 디스패치를 만들지 않고 PostDetailPageController를 직접 호출합니다. 따라서 실패가 Spring 설정이나 Servlet 동작에 가려지지 않습니다.
유효한 ID에서는 PostQuery 호출 횟수와 실제 인자를 함께 확인합니다. 결과는 고정된 post-detail과 키가 하나뿐인 모델이며, 값의 런타임 타입도 정확히 PostPageModel입니다. 페이지 모델의 record component를 검사하는 테스트는 필드 이름과 long, String, int, boolean 타입을 모두 고정합니다. moderationNote는 도메인에는 있지만 페이지 모델에는 없습니다.
유효하지 않은 입력 표본마다 새 쿼리 대역을 만들기 때문에 “전체 반복이 끝났을 때 총 0회”가 아니라 각 요청이 조회 전에 실패했음을 증명합니다. 누락과 빈 목록, 두 값, 공백, 숫자가 아닌 값, 범위 초과, 0과 음수가 모두 이 계약에 들어갑니다.
ModelView의 Map.copyOf는 바깥 맵 entry 집합의 수정 불가능한 스냅샷만 만듭니다. 원본 맵에 나중에 키를 추가해도 결과에는 나타나지 않고 결과 맵의 put은 실패합니다. 그러나 entry가 가리키는 가변 값 객체는 자동으로 깊은 복사되거나 동결되지 않습니다. 테스트가 목록 변경을 관찰하도록 한 이유가 바로 이 얕은 경계를 과장하지 않기 위해서입니다. 실제 페이지 경로의 PostPageModel은 primitive와 String 필드만 가지므로 중첩된 가변 값이 없습니다.
Spring 어댑터가 추가로 고정하는 것
Spring MVC에서는 한 인터페이스가 모든 단계를 처리하지 않습니다. 요청 처리 중 각 확장점의 역할은 다음처럼 다릅니다.
| 단계 | Spring MVC 역할 | 이 예제의 관찰점 |
|---|---|---|
| 메서드 인자 준비 | HandlerMethodArgumentResolver 계열이 @RequestParam List<String>과 Model 인자를 준비 | id의 원시 다중 값이 어댑터 메서드에 도착 |
| 애플리케이션 입력 검증 | RequiredPositiveId가 정확히 한 값과 양수 범위를 검사 | 실패하면 쿼리 0회 |
| 컨트롤러 실행 | SpringPageAdapter가 순수 컨트롤러를 호출하고 속성을 Model에 추가 | 유효하면 쿼리 1회와 정확한 PostPageModel |
| 반환 값 해석 | 반환 값 핸들러가 String을 응답 본문이 아닌 논리 뷰 이름으로 처리 | ModelAndView의 이름이 post-detail |
| 뷰 선택 | ViewResolver가 논리 이름을 실제 View로 대응 | 정확한 허용 목록 항목만 렌더링 |
| 실패 변환 | HandlerExceptionResolver 체인에서 @ResponseStatus 예외가 상태로 해석 | 숫자가 아닌 ID가 HTTP 400, 쿼리 0회 |
HandlerMethodArgumentResolver는 메서드 인자를 만들고, 반환 값 핸들러는 컨트롤러가 돌려준 값을 MVC 의미로 해석합니다. 둘 다 템플릿 파일을 찾지 않습니다. ViewResolver가 논리 이름을 View로 바꾸고, 그 뒤에야 View가 모델을 렌더링합니다.
또한 Java 예외를 던졌다는 사실만으로 HTTP 상태가 생기지는 않습니다. 이 예제에서는 BadRequestException의 @ResponseStatus(BAD_REQUEST)를 Spring의 예외 리졸버가 해석하기 때문에 standalone MockMvc 결과가 400이 됩니다. 순수 테스트에서는 같은 예외를 그대로 관찰하고, MockMvc 테스트에서는 해석된 HTTP 상태까지 관찰합니다.
이 예제는 @RequestParam long id로 바로 변환하지 않고 List<String>을 받습니다. 그래야 중복 값까지 애플리케이션의 “정확히 하나” 계약으로 검사할 수 있습니다. 다른 컨트롤러에서 Spring 타입 변환에 숫자 변환을 맡긴다면 변환 실패 예외와 그 예외를 400으로 바꾸는 리졸버가 별도 단계임을 기억해야 합니다.
CONSUMER CONTRACTS × ROLE-SCOPED ORACLES
소비 경계가 달라지면 model과 test oracle도 달라진다
DB·JSON·HTML에 giant DTO 하나를 공유하지 않습니다. 각 model은 소비자가 필요로 하는 값만 포함하고, 각 test는 그 역할의 관찰 가능한 출력까지만 검증합니다.
01 · DOMAIN ≠ API
Post와 PostResponse는 서로 다른 소비 계약이다
Post는 domain의 업무 불변식과 저장 상태를,
PostResponse는 API client에 공개할 JSON schema를
포함합니다. 하나의 DTO를 DB와 JSON에 공유하지 않습니다.
02 · PAGE MODEL ≠ MVC MODEL
PageModel은 표시 값을, ModelView와 Spring Model은 전달을 소유한다
PostPageModel은 view가 읽을 primitive·String·flag를
담고 HTML을 제외합니다. ModelView는 fixed view name과
named model을 운반합니다. controller 반환값이 논리 이름이고 Spring
Model 자체에는 named attributes만 들어갑니다.
03 · CONTROLLER / SPRING ORACLES
controller와 Spring adapter test는 서로 다른 출력에서 멈춘다
controller test는 multi-value parameter map에서 예외 또는
ModelView, query ID와 call count까지 봅니다. Spring
adapter와 resolver test는 mock Servlet GET에서 200/400, logical
view, Spring Model, render count까지 봅니다.
04 · TEMPLATE / REPOSITORY ORACLES
rendering과 data integration도 입력과 결과를 교차 검증하지 않는다
template test는 completed page model에서 required element와
context-escaped HTML을, repository adapter test는 positive ID에서
Post 또는 not-found를 검증합니다. template은
query를, repository는 view name과 HTML을 검증하지 않습니다.
Map.copyOf는 model map의 shallow snapshot만 만듭니다.
중첩 객체의 불변성은 별도 계약입니다. 이 문서의
RecordingView는 template oracle을 실행하지 않습니다. 실제
template의 HTML text·URL·attribute·JavaScript·CSS context는 각 문맥에
맞는 인코딩을 별도로 검증합니다.
같은 model이라는 말을 섞지 않기
model이라는 단어는 문맥에 따라 다른 대상을 가리킵니다. 소유권을 분명히 하려면 타입과 소비자를 함께 말해야 합니다.
| 타입 | 소유 경계 | 소비자 | 포함하지 않는 것 |
|---|---|---|---|
Post | 도메인·애플리케이션 | 페이지 모델 매퍼, 다른 사용 사례 | 템플릿 경로와 HTML 조각 |
PostResponse | HTTP API | JSON 메시지 컨버터와 API 클라이언트 | HTML 화면 전용 플래그 |
PostPageModel | 페이지 표현 | 등록된 템플릿 | 내부 검토 메모, 저장소 객체, 완성된 HTML |
ModelView | 순수 MVC 계약 | Spring 어댑터 또는 프런트 컨트롤러 | Servlet 상태와 렌더링 기능 |
Spring Model | Spring MVC 요청 처리 | 반환 값 처리기와 선택된 View | 논리 뷰 이름, 도메인 규칙, 조회 기능 |
PostResponse와 PostPageModel이 우연히 같은 필드를 가질 수는 있지만, 변경 계약은 다릅니다. 공개 JSON 스키마의 호환성 때문에 유지해야 하는 필드와 한 화면에서만 필요한 배지 플래그를 같은 거대 DTO에 넣지 않습니다.
컨트롤러 테스트는 입력과 쿼리 호출, 논리 뷰, 타입 있는 모델을 관찰합니다. 템플릿 테스트는 주어진 모델에서 생성한 마크업을 관찰합니다. 리포지토리 어댑터 테스트는 저장소 질의와 행 매핑을 관찰합니다. 어느 한 테스트가 다른 계층의 내부 구현까지 주장하지 않습니다.
| 대상 | 입력 | 관찰 가능한 출력 | 여기서 주장하지 않는 것 |
|---|---|---|---|
| 순수 컨트롤러 | 다중 값 파라미터 맵 | 예외 또는 ModelView, 쿼리 호출 기록 | HTTP 상태, 뷰 렌더링 |
| Spring 어댑터 | mock Servlet GET 요청 | 200/400, 논리 뷰, Spring Model, 리졸버 호출 뒤 렌더 횟수 | 실제 소켓, Boot 전체 설정 |
| 템플릿 | PostPageModel | 문맥에 맞게 이스케이프된 HTML | 쿼리 호출과 업무 계산 |
| 리포지토리 어댑터 | 양의 ID | Post 또는 찾을 수 없음 | 페이지 모델과 뷰 선택 |
뷰 허용 목록과 렌더링 경계
일반적인 ViewResolver 계약은 다음 리졸버가 시도할 수 있도록 null을 반환할 수 있습니다. 이 문서의 ExactViewRegistry는 의도적으로 단일하고 닫힌 레지스트리입니다. post-detail만 정확히 일치시키고, 모르는 이름은 실패시키며, /, \\, .., URI 스킴, .jsp, .html처럼 경로로 해석될 수 있는 입력도 즉시 거절합니다. 접두사·접미사 결합이나 폴백은 없습니다.
테스트용 RecordingView는 고정 문자열만 쓰고 모델 텍스트를 마크업으로 렌더링하지 않습니다. 따라서 이 compilation unit은 JSP나 Thymeleaf의 이스케이프 정확성을 증명하지 않습니다. 그 경계는 앞 문서의 실제 Jasper·JSTL·Thymeleaf 실행 테스트에서 c:out, th:text, 명시적 비이스케이프 출력의 차이까지 검증했습니다.
실제 템플릿에서는 PostPageModel.bodyText와 title을 텍스트로 출력하고 해당 출력 문맥에 맞는 인코딩을 사용합니다. HTML 텍스트 이스케이프 하나를 URL, HTML 속성, JavaScript, CSS 문맥에 일반화하면 안 됩니다. 컨트롤러는 이 값을 미리 이스케이프하거나 “안전한 HTML 문자열”로 표시하지 않습니다.
standalone MockMvc는 실제 서버 포트를 열지 않고 지정한 컨트롤러와 수동으로 등록한 MVC 인프라를 실행합니다. 따라서 이 테스트는 애플리케이션의 Boot 자동 구성, 운영 ViewResolver 등록 순서, 템플릿 파일 존재, Servlet 컨테이너 포워드, 실제 브라우저 렌더링을 증명하지 않습니다. 그 범위는 WebApplicationContext 통합 테스트와 실제 서빙 계층 테스트로 확장합니다.
공식 기준
- Spring Boot 4.1.1 시스템 요구 사항
- Spring Boot 관리 의존성 좌표
- Java SE 25
Map.copyOf - Spring MVC 메서드 인자
- Spring MVC 반환 값
- Spring MVC 뷰 해석
- Spring MVC 예외 리졸버
- Spring MVC 테스트
- Spring MockMvc
- JUnit 6.0.3 사용자 가이드
연습 문제
게시글 목록 화면에 같은 경계를 적용하세요. page와 size는 각각 정확히 한 개의 양의 정수여야 하고, size의 최댓값은 100으로 고정합니다. 모든 입력 검증이 끝난 뒤 목록 쿼리를 한 번 호출하고, PostListItemPageModel과 페이지 메타데이터만 post-list 모델에 넣습니다.
순수 테스트에서는 누락·중복·숫자 아님·0·음수·최댓값 초과가 쿼리 전에 실패하는지 확인합니다. Spring standalone 테스트에서는 유효한 요청의 논리 뷰와 타입 있는 모델, 잘못된 요청의 400을 확인합니다. 뷰 레지스트리는 post-detail과 post-list 두 정확한 항목으로 구성하되 사용자 입력으로 등록 항목을 선택하지 않습니다.
점검 기준 보기
- 입력 변환기는 HTTP나 템플릿 타입을 알지 않고 원시 다중 값에서 검증된 숫자만 만듭니다.
- 목록 컨트롤러는 리포지토리 구현이 아니라 조회 포트를 호출합니다.
PostListItemPageModel은 primitive와String만 포함하고 도메인 객체나 HTML 조각을 포함하지 않습니다.ModelView.viewName()은 상수post-list입니다.- 뷰 레지스트리는 정확한 이름 매핑만 사용하고 경로 결합이나 폴백을 추가하지 않습니다.
- 컨트롤러, Spring 어댑터, 템플릿, 리포지토리 테스트가 각자의 관찰 가능한 출력만 검증합니다.
다음 문서에서는 이 컨트롤러 계약을 프런트 컨트롤러 하나가 선택하고 실행하게 만들면서 URL 매핑, 공통 처리, 404 경계를 분리합니다.