본문으로 건너뛰기

안동민 개발노트

본문 시작

MVC 역할 경계

다중 값 요청에서 양의 ID 하나를 검증하고, 조회·페이지 모델·논리 뷰 허용 목록을 분리한 뒤 순수 단위 테스트와 Spring standalone MockMvc 테스트로 서로 다른 계약을 검증합니다.

MVC의 목적은 클래스 세 개에 Controller, Model, View라는 이름을 붙이는 데 있지 않습니다.

입력 형식, 조회 규칙, 화면에 공개할 데이터, 논리 뷰 선택, 마크업 생성은 서로 다른 이유로 바뀝니다. 이 변경 이유를 경계로 만들면 컨트롤러는 요청에서 나온 경로나 HTML을 조립하지 않고, 템플릿은 저장소를 조회하거나 업무 규칙을 다시 계산하지 않습니다.

이 문서에서는 게시글 상세 화면을 다음 계약으로 고정합니다.

  • 요청에는 id 값이 정확히 하나 있어야 하고, 그 값은 long 범위의 양수여야 합니다.
  • 유효성 검사가 끝나기 전에는 PostQuery를 호출하지 않습니다.
  • 컨트롤러는 유효한 요청마다 쿼리를 정확히 한 번 호출합니다.
  • 도메인 Post를 템플릿에 넘기지 않고, 원시 타입과 String만 가진 PostPageModel로 변환합니다.
  • 논리 뷰 이름은 상수 post-detail이며, 정확한 허용 목록에 등록된 뷰만 해석합니다.
  • HTML 이스케이프와 마크업 선택은 렌더링 계층의 책임으로 남깁니다.
요청 어댑터가 파라미터를 만들고 controller가 양의 ID를 검증한 뒤 PostQuery로 Post를 조회합니다. page-model mapper가 표시할 값과 의미 플래그를 불변 PostPageModel record로 투영하고, controller는 고정 논리 이름 post-detail과 이름 붙은 typed model을 ModelView로 반환합니다. 정확히 등록된 resolver만 고정된 내부 View를 선택하고 view는 준비된 model로 markup을 만듭니다. 요청은 view 경로를 선택하지 않고, Post는 template로 직접 가지 않으며, controller는 HTML을 작성하지 않습니다.

REQUEST ADAPTER → CONTROLLER OUTPUT → REGISTERED VIEW

ModelView가 controller의 결정과 view rendering을 분리한다

controller는 입력 검증, use case 호출, page model 변환, 고정 논리 이름 선택까지 맡습니다. resolver와 내부 view는 그 출력 계약만 받아 선택된 View와 markup을 완성합니다.

ModelView를 경계로 나뉜 controller 결정과 view rendering 파라미터 어댑터, 양의 ID 검증, PostQuery, Post, page-model mapper, 고정 논리 이름과 typed model을 담은 ModelView, 정확히 등록된 resolver, 등록된 고정 내부 View, markup 순서입니다. 요청의 view 경로 선택, domain의 template 직접 전달, controller의 HTML 작성은 금지됩니다. BOUNDARY GUARDRAILS REQUEST INPUT id 값만 입력 · view path 선택 불가 DOMAIN HANDOFF Post는 mapper에서 종료 · template 직행 금지 CONTROLLER OUTPUT ModelView까지 · HTML 작성 금지 CONTROLLER DECISION 입력 → use case → 표현 model → 고정 논리 이름 VIEW RENDERING exact lookup → internal view → markup REQUEST parameter adapter id values VALIDATE positive ID required · numeric id > 0 APPLICATION PostQuery required(id) DOMAIN Post internal values MAPPER page-model PostPageModel text · flag OUTPUT CONTRACT ModelView "post-detail" post: PostPageModel RESOLVER exact registry key lookup FIXED VIEW internal View registered OUTPUT markup text · flag no query FAIL FAST invalid input → query invocation 0 missing · non-numeric · non-positive ID는 use case 전에 거절 FIXED OUTPUT "post-detail" + named typed model model은 표현 가능한 값만 포함 EXACT RESOLUTION post-detail → registered View fixed implementation · no path synthesis path-like name과 unknown name은 실패 READING KEY responsibility handoff ModelView output boundary

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이다

viewNamepost-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입니다. 요청 파라미터를 뷰 이름이나 파일 경로로 사용하지 않습니다. 마지막으로 정확한 허용 목록 레지스트리가 이 이름을 고정된 뷰 구현에 대응시킵니다. 이름에 접두사와 접미사를 붙여 임의 경로를 만드는 방식은 사용하지 않습니다.

이 흐름에는 세 가지 금지선이 있습니다.

  1. 요청 값으로 뷰 경로나 템플릿 파일을 선택하지 않습니다.
  2. 도메인 객체나 저장소 행을 템플릿에 직접 공개하지 않습니다.
  3. 컨트롤러가 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를 통과하는지 검증합니다. 두 테스트는 서로를 대체하지 않습니다.

src/test/java/board/mvc/MvcRoleBoundaryTest.java
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과 음수가 모두 이 계약에 들어갑니다.

ModelViewMap.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으로 바꾸는 리졸버가 별도 단계임을 기억해야 합니다.

Post는 domain의 업무 불변식과 저장 상태, PostResponse는 API client를 위한 공개 JSON schema, PostPageModel은 view가 소비할 표시 텍스트와 의미 플래그, ModelView는 고정 논리 view 이름과 이름 붙은 model 속성을 포함합니다. Spring MVC에서는 controller가 논리 이름을 반환하고 Model에 이름 붙은 속성을 추가합니다. controller test는 입력에서 ModelView와 query 상호작용까지, Spring adapter와 resolver test는 mock Servlet GET에서 HTTP 상태, 논리 이름, Spring Model, render count까지, template test는 완성된 page model에서 필수 element와 문맥에 맞게 이스케이프된 HTML까지, repository adapter test는 양의 ID에서 domain 매핑과 not-found 결과까지 검증합니다.

CONSUMER CONTRACTS × ROLE-SCOPED ORACLES

소비 경계가 달라지면 model과 test oracle도 달라진다

DB·JSON·HTML에 giant DTO 하나를 공유하지 않습니다. 각 model은 소비자가 필요로 하는 값만 포함하고, 각 test는 그 역할의 관찰 가능한 출력까지만 검증합니다.

네 model 소비 경계와 네 역할별 test oracle 위쪽은 Post, PostResponse, PostPageModel, ModelView와 Spring Model의 소유 역할, 소비자, 포함 값을 비교합니다. 아래쪽은 순수 controller, Spring adapter와 resolver, template, repository adapter 각각에 대해 입력, 관찰 가능한 출력, 검증하지 않는 범위를 나눕니다. MODEL CONSUMERS DOMAIN MODEL Post 소유 · domain 소비 · business rules 포함 · 업무 불변식 internal values · 저장 상태 API RESPONSE PostResponse 소유 · API 소비 · API client 포함 · 공개 JSON schema client contract fields PAGE MODEL PostPageModel 소유 · page boundary 소비 · view / template 포함 · 표시 text · semantic flag primitive · String · HTML 제외 MVC HANDOFF ModelView · Spring Model ModelView · pure MVC → adapter viewName + named model Spring Model · named attrs only → return handler / selected View TEST ORACLE · INPUT → OBSERVABLE OUTPUT ROLE INPUT OBSERVABLE OUTPUT NOT ASSERTED HERE Controller unit boundary multi-value parameter Map valid / invalid ID exception or ModelView query ID · call count HTTP status · view rendering resolver invocation Spring adapter + resolver mock Servlet GET id query values 200 / 400 · logical view Spring Model · render count real socket · Boot config template file · forward Template render boundary completed PostPageModel text · semantic flags required elements context-escaped HTML repository call business threshold calculation Repository adapter data integration positive ID storage fixture / row Post or not-found row mapping · interaction logical view name HTML markup

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 조각
PostResponseHTTP APIJSON 메시지 컨버터와 API 클라이언트HTML 화면 전용 플래그
PostPageModel페이지 표현등록된 템플릿내부 검토 메모, 저장소 객체, 완성된 HTML
ModelView순수 MVC 계약Spring 어댑터 또는 프런트 컨트롤러Servlet 상태와 렌더링 기능
Spring ModelSpring MVC 요청 처리반환 값 처리기와 선택된 View논리 뷰 이름, 도메인 규칙, 조회 기능

PostResponsePostPageModel이 우연히 같은 필드를 가질 수는 있지만, 변경 계약은 다릅니다. 공개 JSON 스키마의 호환성 때문에 유지해야 하는 필드와 한 화면에서만 필요한 배지 플래그를 같은 거대 DTO에 넣지 않습니다.

컨트롤러 테스트는 입력과 쿼리 호출, 논리 뷰, 타입 있는 모델을 관찰합니다. 템플릿 테스트는 주어진 모델에서 생성한 마크업을 관찰합니다. 리포지토리 어댑터 테스트는 저장소 질의와 행 매핑을 관찰합니다. 어느 한 테스트가 다른 계층의 내부 구현까지 주장하지 않습니다.

대상입력관찰 가능한 출력여기서 주장하지 않는 것
순수 컨트롤러다중 값 파라미터 맵예외 또는 ModelView, 쿼리 호출 기록HTTP 상태, 뷰 렌더링
Spring 어댑터mock Servlet GET 요청200/400, 논리 뷰, Spring Model, 리졸버 호출 뒤 렌더 횟수실제 소켓, Boot 전체 설정
템플릿PostPageModel문맥에 맞게 이스케이프된 HTML쿼리 호출과 업무 계산
리포지토리 어댑터양의 IDPost 또는 찾을 수 없음페이지 모델과 뷰 선택

뷰 허용 목록과 렌더링 경계

일반적인 ViewResolver 계약은 다음 리졸버가 시도할 수 있도록 null을 반환할 수 있습니다. 이 문서의 ExactViewRegistry는 의도적으로 단일하고 닫힌 레지스트리입니다. post-detail만 정확히 일치시키고, 모르는 이름은 실패시키며, /, \\, .., URI 스킴, .jsp, .html처럼 경로로 해석될 수 있는 입력도 즉시 거절합니다. 접두사·접미사 결합이나 폴백은 없습니다.

테스트용 RecordingView는 고정 문자열만 쓰고 모델 텍스트를 마크업으로 렌더링하지 않습니다. 따라서 이 compilation unit은 JSP나 Thymeleaf의 이스케이프 정확성을 증명하지 않습니다. 그 경계는 앞 문서의 실제 Jasper·JSTL·Thymeleaf 실행 테스트에서 c:out, th:text, 명시적 비이스케이프 출력의 차이까지 검증했습니다.

실제 템플릿에서는 PostPageModel.bodyTexttitle을 텍스트로 출력하고 해당 출력 문맥에 맞는 인코딩을 사용합니다. HTML 텍스트 이스케이프 하나를 URL, HTML 속성, JavaScript, CSS 문맥에 일반화하면 안 됩니다. 컨트롤러는 이 값을 미리 이스케이프하거나 “안전한 HTML 문자열”로 표시하지 않습니다.

standalone MockMvc는 실제 서버 포트를 열지 않고 지정한 컨트롤러와 수동으로 등록한 MVC 인프라를 실행합니다. 따라서 이 테스트는 애플리케이션의 Boot 자동 구성, 운영 ViewResolver 등록 순서, 템플릿 파일 존재, Servlet 컨테이너 포워드, 실제 브라우저 렌더링을 증명하지 않습니다. 그 범위는 WebApplicationContext 통합 테스트와 실제 서빙 계층 테스트로 확장합니다.


공식 기준


연습 문제

게시글 목록 화면에 같은 경계를 적용하세요. pagesize는 각각 정확히 한 개의 양의 정수여야 하고, size의 최댓값은 100으로 고정합니다. 모든 입력 검증이 끝난 뒤 목록 쿼리를 한 번 호출하고, PostListItemPageModel과 페이지 메타데이터만 post-list 모델에 넣습니다.

순수 테스트에서는 누락·중복·숫자 아님·0·음수·최댓값 초과가 쿼리 전에 실패하는지 확인합니다. Spring standalone 테스트에서는 유효한 요청의 논리 뷰와 타입 있는 모델, 잘못된 요청의 400을 확인합니다. 뷰 레지스트리는 post-detailpost-list 두 정확한 항목으로 구성하되 사용자 입력으로 등록 항목을 선택하지 않습니다.

점검 기준 보기
  • 입력 변환기는 HTTP나 템플릿 타입을 알지 않고 원시 다중 값에서 검증된 숫자만 만듭니다.
  • 목록 컨트롤러는 리포지토리 구현이 아니라 조회 포트를 호출합니다.
  • PostListItemPageModel은 primitive와 String만 포함하고 도메인 객체나 HTML 조각을 포함하지 않습니다.
  • ModelView.viewName()은 상수 post-list입니다.
  • 뷰 레지스트리는 정확한 이름 매핑만 사용하고 경로 결합이나 폴백을 추가하지 않습니다.
  • 컨트롤러, Spring 어댑터, 템플릿, 리포지토리 테스트가 각자의 관찰 가능한 출력만 검증합니다.

다음 문서에서는 이 컨트롤러 계약을 프런트 컨트롤러 하나가 선택하고 실행하게 만들면서 URL 매핑, 공통 처리, 404 경계를 분리합니다.