본문 바로가기

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의 처리와 실패 차단이 있다.

요청 어댑터가 파라미터를 만들고 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로 전달하는 MVC 역할다중 값 id를 정확한 양수 하나로 검증한 뒤 PostQuery를 한 번 호출합니다. mapper가 page model을 만들고 fixed post-detail ModelView를 반환합니다. exact registry가 RecordingView를 선택합니다. 이 fixture의 view는 고정 표식이며 실제 template markup 검증은 별도입니다.
다중 값 id정확히 한 양수유효하지 않으면 query 0
연결 관계다중 값 id · 정확히 한 양수 · 유효하지 않으면 query 0 → PostQuery.required · 유효 입력당 1회 · Post 조회
PostQuery.required유효 입력당 1회Post 조회
연결 관계PostQuery.required · 유효 입력당 1회 · Post 조회 → page-model mapper · PostPageModel · primitive · String
page-model mapperPostPageModelprimitive · String
연결 관계page-model mapper · PostPageModel · primitive · String → ModelView · post-detail 고정 · post: page model
ModelViewpost-detail 고정post: page model
연결 관계ModelView · post-detail 고정 · post: page model → ExactViewRegistry · 이름의 정확한 조회 · 등록 View 선택
ExactViewRegistry이름의 정확한 조회등록 View 선택
연결 관계ExactViewRegistry · 이름의 정확한 조회 · 등록 View 선택 → RecordingView · fixed test view · 실제 template는 별도
RecordingViewfixed test view실제 template는 별도

controller 결정 → 전달 계약 → resolver / view

검증과 ModelView를 통과하는 MVC 모바일 흐름기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을ModelView로 전달한다. exact registry가RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다.다중 값 id정확히 한 양수유효하지 않으면query 0PostQuery.required유효 입력당 1회Post 조회page-model mapperPostPageModelprimitive · StringModelViewpost-detail 고정post: page modelExactViewRegistry이름의 정확한 조회등록 View 선택RecordingViewfixed test view실제 template는 별도

화살표는 검증을 통과한 조회 경로입니다. 입력 거절에서 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의 처리와 실패 차단이 있다.

요청 어댑터가 파라미터를 만들고 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로 전달하는 MVC 역할다중 값 id를 정확한 양수 하나로 검증한 뒤 PostQuery를 한 번 호출합니다. mapper가 page model을 만들고 fixed post-detail ModelView를 반환합니다. exact registry가 RecordingView를 선택합니다. 이 fixture의 view는 고정 표식이며 실제 template markup 검증은 별도입니다.
다중 값 id정확히 한 양수유효하지 않으면 query 0
연결 관계다중 값 id · 정확히 한 양수 · 유효하지 않으면 query 0 → PostQuery.required · 유효 입력당 1회 · Post 조회
PostQuery.required유효 입력당 1회Post 조회
연결 관계PostQuery.required · 유효 입력당 1회 · Post 조회 → page-model mapper · PostPageModel · primitive · String
page-model mapperPostPageModelprimitive · String
연결 관계page-model mapper · PostPageModel · primitive · String → ModelView · post-detail 고정 · post: page model
ModelViewpost-detail 고정post: page model
연결 관계ModelView · post-detail 고정 · post: page model → ExactViewRegistry · 이름의 정확한 조회 · 등록 View 선택
ExactViewRegistry이름의 정확한 조회등록 View 선택
연결 관계ExactViewRegistry · 이름의 정확한 조회 · 등록 View 선택 → RecordingView · fixed test view · 실제 template는 별도
RecordingViewfixed test view실제 template는 별도

controller 결정 → 전달 계약 → resolver / view

검증과 ModelView를 통과하는 MVC 모바일 흐름기존 가로 그림과 같은 여섯 노드와 다섯 관계이다. 다중 값에서 정확히 한 양수 ID를 검증하고, 유효할 때만 PostQuery를 한 번 호출한다. mapper가 primitive와String 페이지 모델을 만들고 controller는 고정 post-detail 이름과 model을ModelView로 전달한다. exact registry가RecordingView를 선택한다. 이 고정 표식 view는 실제 template escaping을 검증하지 않는다.다중 값 id정확히 한 양수유효하지 않으면query 0PostQuery.required유효 입력당 1회Post 조회page-model mapperPostPageModelprimitive · StringModelViewpost-detail 고정post: page modelExactViewRegistry이름의 정확한 조회등록 View 선택RecordingViewfixed test view실제 template는 별도

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

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

  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는 도메인에는 있지만 페이지 모델에는 없습니다.

조회 대역은 실제 저장소의 존재 여부를 증명하지 않습니다. 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으로 바꾸는 리졸버가 별도 단계임을 기억해야 합니다.

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는 그 역할의 관찰 가능한 출력까지만 검증합니다.

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

PostResponse와 PostPageModel이 우연히 같은 필드를 가질 수는 있지만, 변경 계약은 다릅니다. 공개 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.bodyText와 title을 텍스트로 출력하고 해당 출력 문맥에 맞는 인코딩을 사용합니다. HTML 텍스트 이스케이프 하나를 URL, HTML 속성, JavaScript, CSS 문맥에 일반화하면 안 됩니다. 컨트롤러는 이 값을 미리 이스케이프하거나 “안전한 HTML 문자열”로 표시하지 않습니다.

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


공식 기준


연습 문제

게시글 목록 화면에 같은 경계를 적용하세요. 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·성능 제한은 이번 산술 계약과 별도로 정합니다.

추가 연습의 풀이 확인
  1. +0042는 값 42로 변환되어 query 1회·인자 42입니다. 공백을 포함한 42는 parseLong에서 실패하고, 같은 값 두 개도 목록 크기 검사를 통과하지 못하므로 두 경우 모두 query 0회입니다. 이 직접 호출의 결과는 예외이며, HTTP 400은 Spring 어댑터의 예외 리졸버가 추가로 해석하는 계약입니다.
  2. query에는 99가 기록되지만 대역이 지정된 ID 42 게시글을 반환하므로 페이지 ID는 42, 80>=60이어서 badge는 true입니다. 이를 실제 저장소가 ID 99를 찾았다는 증거로 사용할 수 없습니다. 실제 조회에서 요청 ID에 대응하는 게시글을 반환하고, 없으면 정해진 not-found 결과를 내는지는 별도 구현·테스트의 책임입니다.
  3. 첫 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을 수용하거나 빠르게 처리한다는 보장은 아닙니다.