MVC 역할 경계
다중 값 요청에서 양의 ID 하나를 검증하고, 조회·페이지 모델·논리 뷰 허용 목록을 분리한 뒤 순수 단위 테스트와 Spring standalone MockMvc 테스트로 서로 다른 계약을 검증합니다.
MVC의 목적은 클래스 세 개에 Controller, Model, View라는 이름을 붙이는 데 있지 않습니다.
입력 형식, 조회 규칙, 화면에 공개할 데이터, 논리 뷰 선택, 마크업 생성은 서로 다른 이유로 바뀝니다. 이 변경 이유를 경계로 만들면 컨트롤러는 요청에서 나온 경로나 HTML을 조립하지 않고, 템플릿은 저장소를 조회하거나 업무 규칙을 다시 계산하지 않습니다.
이 문서에서는 게시글 상세 화면을 다음 계약으로 고정합니다.
- 요청에는
id값이 정확히 하나 있어야 하고, 그 값은long범위의 양수여야 합니다. - 유효성 검사가 끝나기 전에는
PostQuery를 호출하지 않습니다. - 컨트롤러는 유효한 요청마다 쿼리를 정확히 한 번 호출합니다.
- 도메인
Post를 템플릿에 넘기지 않고, 원시 타입과String만 가진PostPageModel로 변환합니다. - 논리 뷰 이름은 상수
post-detail이며, 정확한 허용 목록에 등록된 뷰만 해석합니다. - HTML 이스케이프와 마크업 선택은 렌더링 계층의 책임으로 남깁니다.
ModelView가 controller의 결정과 view rendering을 분리한다
id 검증·query 1회·page model·ModelView·registry·test View의 처리와 실패 차단이 있다.
REQUEST ADAPTER → CONTROLLER OUTPUT → REGISTERED VIEW
ModelView가 controller의 결정과 view rendering을 분리한다
controller는 입력 검증, use case 호출, page model 변환, 고정 논리 이름 선택까지 맡습니다. resolver와 내부 view는 그 출력 계약만 받아 선택된 View와 markup을 완성합니다.
controller 결정 → 전달 계약 → resolver / view
화살표는 검증을 통과한 조회 경로입니다. 입력 거절에서 query·view로 계속 진행하지 않습니다. 모델 텍스트를 완성된 HTML로 바꾸는 책임은 이 RecordingView fixture의 검증 범위가 아닙니다.
01 · REQUEST BOUNDARY
adapter는 파라미터를 만들고 controller는 양의 ID를 먼저 검증한다
누락·빈 값·중복·숫자 아님·범위 초과·0·음수는 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를 호출하지 않습니다. 이 fixture의 RecordingView는 고정 표식만 쓰며 template escaping은 검증하지 않습니다.
ModelView는 controller와 rendering 사이의 출력
계약입니다. 역할은 서로를 탐색하지 않고, controller → resolver →
view로 명시적인 값만 전달합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 다중 값 id를 정확한 양수 하나로 검증한 뒤 PostQuery를 한 번 호출합니다. mapper가 page model을 만들고 fixed post-detail ModelView를 반환합니다. exact registry가 RecordingView를 선택합니다. 이 fixture의 view는 고정 표식이며 실제 template markup 검증은 별도입니다.
- 기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와 String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을 ModelView로 전달한다. exact registry가 RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 24 | 실제 template은 별도 |
| 27 | 기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와 String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을 ModelView로 전달한다. exact registry가 RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| id 준비와 query 다중 값 id를 정확한 양수 하나로 검사하고 query를 한 번 호출합니다. | id: 양수 하나 · query: 1회 | 유효 입력만 조회 결과를 만듭니다. |
| ModelView 반환 mapper가 PostPageModel을 만들고 controller가 고정 post-detail 이름과 post 모델을 ModelView로 반환합니다. | mapper 산출물: PostPageModel · controller 산출물: ModelView: post-detail, post 모델 | controller 결정이 view 계약으로 바뀝니다. |
| registry와 test View exact registry가 RecordingView를 골라 고정 표식을 출력합니다. | 출력: 고정 test marker · template markup: 별도 검사 | 선택한 view의 실행만 검증합니다. |
ModelView가 controller의 결정과 view rendering을 분리한다
id 검증·query 1회·page model·ModelView·registry·test View의 처리와 실패 차단이 있다.
REQUEST ADAPTER → CONTROLLER OUTPUT → REGISTERED VIEW
ModelView가 controller의 결정과 view rendering을 분리한다
controller는 입력 검증, use case 호출, page model 변환, 고정 논리 이름 선택까지 맡습니다. resolver와 내부 view는 그 출력 계약만 받아 선택된 View와 markup을 완성합니다.
controller 결정 → 전달 계약 → resolver / view
화살표는 검증을 통과한 조회 경로입니다. 입력 거절에서 query·view로 계속 진행하지 않습니다. 모델 텍스트를 완성된 HTML로 바꾸는 책임은 이 RecordingView fixture의 검증 범위가 아닙니다.
01 · REQUEST BOUNDARY
adapter는 파라미터를 만들고 controller는 양의 ID를 먼저 검증한다
누락·빈 값·중복·숫자 아님·범위 초과·0·음수는 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를 호출하지 않습니다. 이 fixture의 RecordingView는 고정 표식만 쓰며 template escaping은 검증하지 않습니다.
ModelView는 controller와 rendering 사이의 출력
계약입니다. 역할은 서로를 탐색하지 않고, controller → resolver →
view로 명시적인 값만 전달합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 다중 값 id를 정확한 양수 하나로 검증한 뒤 PostQuery를 한 번 호출합니다. mapper가 page model을 만들고 fixed post-detail ModelView를 반환합니다. exact registry가 RecordingView를 선택합니다. 이 fixture의 view는 고정 표식이며 실제 template markup 검증은 별도입니다.
- 기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와 String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을 ModelView로 전달한다. exact registry가 RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 24 | 실제 template은 별도 |
| 27 | 기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와 String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을 ModelView로 전달한다. exact registry가 RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| id 준비와 query 다중 값 id를 정확한 양수 하나로 검사하고 query를 한 번 호출합니다. | id: 양수 하나 · query: 1회 | 유효 입력만 조회 결과를 만듭니다. |
| ModelView 반환 mapper가 PostPageModel을 만들고 controller가 고정 post-detail 이름과 post 모델을 ModelView로 반환합니다. | mapper 산출물: PostPageModel · controller 산출물: ModelView: post-detail, post 모델 | controller 결정이 view 계약으로 바뀝니다. |
| registry와 test View exact registry가 RecordingView를 골라 고정 표식을 출력합니다. | 출력: 고정 test marker · template markup: 별도 검사 | 선택한 view의 실행만 검증합니다. |
요청에서 마크업까지의 단방향 흐름
입력 어댑터는 다중 값 파라미터를 애플리케이션이 이해하는 양의 ID 하나로 바꿉니다. 누락, 빈 값, 중복, 숫자가 아닌 값, long 범위를 넘는 값, 0과 음수는 모두 같은 입력 경계에서 거절합니다. 이 검증을 통과한 뒤에만 컨트롤러가 조회 사용 사례를 호출합니다.
양수라는 값의 조건과 숫자 표기의 규칙은 다릅니다. 이 변환기는 Long.parseLong을 쓰므로 +0042도 값 42로 허용하지만 양끝에 공백이 있는 42는 공백을 제거하지 않아 거절합니다. id=42&id=42도 값이 같다는 이유로 합치지 않고 두 항목이므로 거절합니다. ASCII 숫자만·선행 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는 도메인에는 있지만 페이지 모델에는 없습니다.
조회 대역은 실제 저장소의 존재 여부를 증명하지 않습니다. RecordingPostQuery는 전달받은 ID를 기록하고 생성자에서 지정한 answer를 그대로 반환합니다. 따라서 ID 99를 넣어도 이 대역의 answer가 ID 42인 게시글이면 그 페이지 모델을 돌려줄 수 있습니다. 이 테스트는 호출 인자·횟수와 전달받은 projection의 변환을 검증하며, ID 일치·찾을 수 없음·권한은 실제 조회 계약과 별도 테스트에서 확인합니다. 입력 형식 오류의 400 매핑을 찾을 수 없음의 404 매핑으로 일반화하지 않습니다.
유효하지 않은 입력 표본마다 새 쿼리 대역을 만들기 때문에 “전체 반복이 끝났을 때 총 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가 모델을 렌더링합니다.
같은 String 반환도 어노테이션에 따라 의미가 달라집니다. 여기의 @Controller 메서드에는 @ResponseBody가 없으므로 post-detail은 뷰 이름입니다. @ResponseBody 또는 @RestController로 바꾸면 문자열은 응답 본문 쪽 계약으로 해석되어 뷰 선택을 기대할 수 없습니다. 화면 어댑터와 JSON·본문 API를 연결할 때 반환 타입의 이름만 보지 말고 해당 처리 경계를 함께 확인합니다.
또한 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는 그 역할의 관찰 가능한 출력까지만 검증합니다.
DOMAIN · Post
업무 값과 내부 불변식
도메인·애플리케이션이 소유하고 mapper나 다른 use case가 소비합니다. fixture의 내부 moderationNote는 페이지 모델에 공개하지 않습니다.
API · PostResponse
공개 JSON 스키마
API client와 message converter의 계약입니다. HTML 전용 badge와 같은 화면 변경 이유를 하나의 거대 DTO에 섞지 않습니다. 이 MVC fixture의 반환 타입은 API DTO가 아닙니다.
PAGE · PostPageModel
화면에서 읽을 primitive·String·flag
View가 소비합니다. title과 bodyText는 텍스트 값이며 완성된 HTML이나 이미 안전한 HTML 표식이 아닙니다. domain 객체·repository·내부 메모를 제외합니다.
PURE MVC · ModelView
논리 view name + named model
순수 controller의 출력입니다. 상수 post-detail과 이름 붙은 page model을 adapter로 전달합니다. Map.copyOf는 map의 얕은 snapshot이며 내부 값 객체는 별도 불변성 계약입니다.
SPRING MVC · Model
이름 붙은 속성만 운반한다
Spring adapter가 속성을 추가하고 별도로 논리 이름 String을 반환합니다. 반환 값 처리기가 그 이름을 view 의미로 해석하고 ViewResolver가 View를 선택합니다. Model 자체가 논리 이름·조회·업무 규칙을 소유하지 않습니다.
PURE CONTROLLER TEST
parameter map → 예외 / ModelView
query ID·호출 횟수와 정확한 page model 타입을 관찰합니다. HTTP 상태와 실제 view 렌더링은 이 직접 호출 테스트의 결과가 아닙니다.
STANDALONE MOCKMVC TEST
mock GET → 200/400·view·model·render count
수동 등록한 MVC resolver까지 실행합니다. 실제 socket·Boot 전체 자동 구성·템플릿 파일·컨테이너 forward·브라우저 렌더링을 증명하지 않습니다.
SEPARATE TEMPLATE TEST
page model → 마크업과 문맥별 escaping
고정 문자열을 쓰는 이 RecordingView는 template oracle을 실행하지 않습니다. Jasper·Thymeleaf text escaping은 앞 문서가 검증하며 URL·attribute·JavaScript·CSS도 각각 맞는 문맥 계약이 필요합니다.
SEPARATE REPOSITORY TEST
양의 ID → Post / 찾을 수 없음
저장소 질의·row mapping·상호작용을 관찰하는 별도 경계입니다. 논리 view 이름이나 HTML 마크업을 repository 검증에 합치지 않습니다.
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 경계를 분리합니다.
추가 연습 — 형식 검증과 조회 대역, 목록 경계를 구분합니다. (1) 각각 새 RecordingPostQuery를 사용해 id 목록 ["+0042"], [" 42 "], ["42","42"]를 순수 컨트롤러에 전달합니다. 각 경우의 검증 결과·query 호출 횟수와 전달 ID를 구하세요. (2) 대역의 answer를 기존 ID 42·contentLength 80인 samplePost로 고정한 채 입력 ["99"]를 처리합니다. 기록된 조회 ID와 결과 페이지 ID·badge, 이것이 증명하지 못하는 저장소 계약을 설명하세요. (3) 앞 목록 연습을 구현할 때 page와 size는 Java int 범위의 양수 하나씩이며 size는 최대 100입니다. 조회 offset은 0부터 시작하는 (page - 1) × size이고, 두 입력을 모두 검증한 뒤 한 번 조회합니다. page=3,size=20 및 page=2,147,483,647,size=100의 offset을 구하세요. 두 번째 계산을 int로 곱한 뒤 long에 대입하는 선택과 곱하기 전에 long으로 변환하는 선택을 비교하고, page=3,size=101의 query 횟수도 구하세요. 실제 DB가 허용하는 최대 offset·성능 제한은 이번 산술 계약과 별도로 정합니다.
추가 연습의 풀이 확인
+0042는 값 42로 변환되어 query 1회·인자 42입니다. 공백을 포함한42는 parseLong에서 실패하고, 같은 값 두 개도 목록 크기 검사를 통과하지 못하므로 두 경우 모두 query 0회입니다. 이 직접 호출의 결과는 예외이며, HTTP 400은 Spring 어댑터의 예외 리졸버가 추가로 해석하는 계약입니다.- query에는 99가 기록되지만 대역이 지정된 ID 42 게시글을 반환하므로 페이지 ID는 42, 80>=60이어서 badge는 true입니다. 이를 실제 저장소가 ID 99를 찾았다는 증거로 사용할 수 없습니다. 실제 조회에서 요청 ID에 대응하는 게시글을 반환하고, 없으면 정해진 not-found 결과를 내는지는 별도 구현·테스트의 책임입니다.
- 첫 offset은 (3-1)×20=40이고 query는 1회입니다. 두 번째 offset은 2,147,483,646×100=214,748,364,600입니다.
(long) (page - 1) * size처럼 곱하기 전에 long으로 계산해야 합니다. 먼저 int로 곱하면 -200으로 overflow되어 이후 long에 대입해도 원래 수치를 복구하지 못합니다. size=101은 상한을 넘으므로 offset 계산·조회 전에 실패하고 query는 0회입니다. 올바른 큰 수를 계산했다는 사실이 데이터베이스가 그 offset을 수용하거나 빠르게 처리한다는 보장은 아닙니다.