본문 바로가기

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를 양수 하나로 검사한 뒤에만 PostQuery.required를 한 번 호출하고, 매퍼가 PostPageModel을 만들면 컨트롤러가 고정 이름 post-detail과 post 모델을 ModelView로 돌려줍니다. 마지막으로 ExactViewRegistry가 이름이 정확히 일치하는 RecordingView를 고릅니다. 입력이 거절되면 조회와 뷰로 넘어가지 않으며, 이 예제의 RecordingView는 고정된 표식만 쓰므로 실제 템플릿의 이스케이프는 확인하지 않습니다.


요청에서 마크업까지의 단방향 흐름

입력 어댑터는 다중 값 파라미터를 애플리케이션이 이해하는 양의 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.2의 의존성 관리, Spring Framework 7.0.10, JUnit 6.0.3을 기준으로 하는 완결된 컴파일 단위(compilation unit)입니다. 컨트롤러와 입력 변환기, 조회 대역, 도메인과 페이지 모델, 뷰 레지스트리, Spring 어댑터를 모두 중첩 타입으로 담습니다.

순수 컨트롤러 테스트는 서블릿 요청 객체나 Spring MVC의 디스패치 없이 입력과 호출 횟수, 결과 객체를 확인합니다. 그 아래의 독립형(standalone) MockMvc 테스트는 모의(mock) 서블릿 요청이 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) {}
}

순수 테스트가 고정하는 것

첫 번째 테스트 묶음은 서블릿 요청이나 Spring MVC의 디스패치 없이 PostDetailPageController를 직접 호출하므로, 실패가 Spring 설정이나 서블릿 동작에 가려지지 않습니다.

올바른 ID에서는 PostQuery의 호출 횟수와 실제 인자를 함께 확인합니다. 결과는 고정된 post-detail과 키가 하나뿐인 모델이며, 값의 런타임 타입도 정확히 PostPageModel입니다. 페이지 모델의 레코드 컴포넌트(record component)를 검사하는 테스트는 필드 이름과 long, String, int, boolean 타입을 모두 고정합니다. moderationNote는 도메인에는 있지만 페이지 모델에는 없습니다.

조회 대역은 실제 저장소에 게시글이 있는지를 보여 주지 않습니다. RecordingPostQuery는 받은 ID를 기록하고 생성자에서 지정한 answer를 그대로 돌려주므로, ID 99를 넣어도 대역의 답이 ID 42인 게시글이면 그 페이지 모델을 돌려줄 수 있습니다. 이 테스트가 확인하는 것은 호출 인자와 횟수, 받은 조회 결과의 변환입니다. ID가 일치하는지, 찾을 수 없을 때와 권한이 없을 때는 실제 조회 구현의 별도 테스트에서 확인하며, 입력 형식 오류의 400을 찾을 수 없음의 404로 일반화하지 않습니다.

잘못된 입력마다 새 조회 대역을 만들기 때문에, 반복이 모두 끝난 뒤 총 0회였다는 것이 아니라 요청 하나하나가 조회 전에 실패했다는 것을 보여 줍니다. 누락과 빈 목록, 두 값, 공백, 숫자가 아닌 값, 범위 초과, 0과 음수가 모두 이 표본에 들어갑니다.

ModelView의 Map.copyOf는 바깥 맵이 가진 항목 집합의 수정할 수 없는 스냅샷만 만듭니다. 원본 맵에 나중에 키를 더해도 결과에는 나타나지 않고 결과 맵의 put은 실패하지만, 항목이 가리키는 가변 값 객체까지 깊게 복사하거나 고정하지는 않습니다. 테스트가 목록의 변경을 일부러 보여 주는 것도 이 얕은 복사를 과장하지 않기 위해서입니다. 실제 페이지 경로의 PostPageModel은 원시 타입과 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의 예외 리졸버가 해석하므로 독립형 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는 각 문맥에 맞는 인코딩을 별도로 검증합니다.

그림의 위쪽 다섯 칸은 소비자가 다른 모델을 나눕니다. Post는 도메인의 값과 불변식, PostResponse는 API 클라이언트의 공개 JSON 스키마, PostPageModel은 뷰가 읽을 표시 값, ModelView와 Spring Model은 논리 이름과 이름 붙은 속성을 운반합니다. 아래쪽 네 칸은 테스트마다 확인하는 출력을 나눕니다. 순수 컨트롤러 테스트는 예외나 ModelView까지, 독립형 MockMvc 테스트는 상태와 뷰, 모델, 렌더 횟수까지, 템플릿 테스트는 문맥에 맞게 이스케이프된 마크업까지, 저장소 테스트는 Post나 찾을 수 없음까지만 봅니다.


같은 model이라는 말을 섞지 않습니다

model이라는 단어는 문맥에 따라 다른 대상을 가리키므로, 누가 만들고 누가 쓰는지를 타입과 함께 말해야 합니다.

타입속한 곳소비자넣지 않는 것
Post도메인과 애플리케이션페이지 모델 매퍼, 다른 사용 사례템플릿 경로와 HTML 조각
PostResponseHTTP APIJSON 메시지 컨버터와 API 클라이언트HTML 화면 전용 플래그
PostPageModel페이지 표현등록된 템플릿내부 검토 메모, 저장소 객체, 완성된 HTML
ModelView순수 MVC의 반환 값Spring 어댑터나 프런트 컨트롤러서블릿 상태와 렌더링 기능
Spring ModelSpring MVC 요청 처리반환 값 처리기와 선택된 View논리 뷰 이름, 도메인 규칙, 조회 기능

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

테스트도 같은 기준으로 나눕니다. 컨트롤러 테스트는 입력과 조회 호출, 논리 뷰, 타입이 있는 모델을 보고, 템플릿 테스트는 주어진 모델로 만든 마크업을 보며, 저장소 어댑터 테스트는 질의와 행 매핑을 봅니다. 어느 테스트도 다른 계층의 내부 구현까지 단정하지 않습니다.

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

뷰 허용 목록과 렌더링 범위

일반적인 ViewResolver는 다음 리졸버가 시도할 수 있도록 null을 돌려줄 수 있습니다. 이 글의 ExactViewRegistry는 일부러 닫아 둔 단일 레지스트리입니다. post-detail과 정확히 일치하는 이름만 받고, 모르는 이름은 실패시키며, /, \\, .., URI 스킴, .jsp, .html처럼 경로로 해석될 수 있는 입력도 바로 거절합니다. 접두사와 접미사의 결합이나 다른 리졸버로 넘기는 폴백은 없습니다.

테스트용 RecordingView는 고정된 문자열만 쓰고 모델의 텍스트를 마크업으로 렌더링하지 않습니다. 그래서 이 컴파일 단위는 JSP나 Thymeleaf의 이스케이프가 올바른지 보여 주지 않습니다. 그 부분은 앞 글의 Jasper·Jakarta Tags·Thymeleaf 테스트가 c:out과 th:text, 이스케이프하지 않는 출력의 차이까지 확인했습니다.

실제 템플릿에서는 PostPageModel의 bodyText와 title을 텍스트로 출력하고 그 출력 문맥에 맞는 인코딩을 씁니다. HTML 텍스트 이스케이프 하나를 URL이나 HTML 속성, JavaScript, CSS 문맥에 그대로 쓰지 않으며, 컨트롤러가 이 값을 미리 이스케이프하거나 안전한 HTML 문자열로 표시하지도 않습니다.

독립형 MockMvc는 서버 포트를 열지 않고, 지정한 컨트롤러와 직접 등록한 MVC 구성 요소만 실행합니다. 따라서 이 테스트는 Boot의 자동 구성과 운영 환경의 ViewResolver 등록 순서, 템플릿 파일의 존재, 서블릿 컨테이너의 포워드, 실제 브라우저의 렌더링을 보여 주지 않습니다. 그 범위는 WebApplicationContext 통합 테스트와 실제 서버를 띄우는 테스트로 넓힙니다.

이 글의 내용은 Spring Boot 시스템 요구 사항과 관리 의존성 좌표, Java SE 25의 Map.copyOf, Spring MVC의 메서드 인자와 반환 값, 뷰 해석, 예외 리졸버, MVC 테스트와 MockMvc, JUnit 6.0.3 사용자 가이드를 기준으로 합니다.


정리

  • 입력 형식과 조회 규칙, 화면에 공개할 값, 논리 뷰 선택, 마크업은 바뀌는 이유가 달라 역할을 나눕니다.
  • 다중 값 id는 정확히 하나의 양수로 검증한 뒤에만 조회하고, 도메인 객체 대신 원시 타입과 String만 가진 페이지 모델을 넘깁니다.
  • 컨트롤러는 고정된 논리 이름과 모델만 돌려주고, 허용 목록 레지스트리가 이름이 정확히 일치하는 뷰만 고릅니다.
  • 순수 테스트는 예외와 ModelView, 조회 기록까지 보고, 독립형 MockMvc는 HTTP 상태와 뷰 해석까지 보지만 실제 서버와 템플릿은 보지 않습니다.
  • Map.copyOf는 얕은 스냅샷이며, 조회 대역의 답은 실제 저장소의 동작을 보여 주지 않습니다.

연습 문제

1. 목록 화면에 같은 구분 적용하기

게시글 목록 화면에 같은 구분을 적용합니다. page와 size는 각각 정확히 한 개의 양의 정수여야 하고, size의 최댓값은 100입니다. 입력을 모두 검증한 뒤 목록 조회를 한 번 호출하고, PostListItemPageModel과 페이지 메타데이터만 post-list 모델에 넣습니다. 순수 테스트와 Spring 독립형 테스트에서 각각 무엇을 확인하고, 뷰 레지스트리는 어떻게 구성해야 할까요?

해설 보기

순수 테스트에서는 누락과 중복, 숫자 아님, 0, 음수, 최댓값 초과가 조회 전에 실패하는지 확인합니다. Spring 독립형 테스트에서는 올바른 요청의 논리 뷰와 타입이 있는 모델, 잘못된 요청의 400을 확인합니다. 뷰 레지스트리는 post-detail과 post-list 두 항목을 정확한 이름으로만 대응시키고, 사용자 입력으로 항목을 고르지 않으며, 경로 결합이나 폴백을 더하지 않습니다.

그 밖에 다음을 지키는지 봅니다. 입력 변환기는 HTTP나 템플릿 타입을 모른 채 원시 다중 값에서 검증된 숫자만 만듭니다. 목록 컨트롤러는 저장소 구현이 아니라 조회 포트를 호출합니다. PostListItemPageModel은 원시 타입과 String만 담고 도메인 객체나 HTML 조각을 담지 않으며, ModelView.viewName()은 상수 post-list입니다. 컨트롤러와 Spring 어댑터, 템플릿, 저장소의 테스트는 각자 확인하는 출력만 검증합니다.

2. 형식 검증과 조회 대역

① 매번 새 RecordingPostQuery로 id 목록 ["+0042"], [" 42 "], ["42","42"]를 순수 컨트롤러에 전달합니다. 각 경우의 검증 결과와 조회 횟수, 전달된 ID를 구하세요. ② 대역의 답을 ID 42, contentLength 80인 samplePost()로 고정한 채 ["99"]를 처리합니다. 기록된 조회 ID와 결과 페이지의 ID, showLongReadBadge를 구하고, 이 결과로 알 수 없는 것을 설명하세요.

해설 보기

① +0042는 값 42로 변환되어 조회 1회, 인자 42입니다. 공백을 포함한 42는 parseLong에서 실패하고, 같은 값 두 개는 목록 크기 검사를 통과하지 못하므로 두 경우 모두 조회 0회입니다. 직접 호출한 결과는 예외이며, HTTP 400은 Spring 어댑터의 예외 리졸버가 따로 해석한 결과입니다.

② 조회에는 99가 기록되지만 대역이 지정된 ID 42의 게시글을 돌려주므로 페이지 ID는 42이고, 80은 60 이상이어서 showLongReadBadge는 참입니다. 이것으로 실제 저장소가 ID 99를 찾았다고 말할 수는 없습니다. 실제 조회가 요청 ID에 맞는 게시글을 돌려주는지, 없을 때 정해진 찾을 수 없음 결과를 내는지는 저장소 구현의 별도 테스트로 확인합니다.

3. offset 계산과 상한

1번의 목록을 구현할 때 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의 조회 횟수도 구하세요.

해설 보기

첫 offset은 (3 − 1) × 20 = 40이고 조회는 1회입니다. 두 번째 offset은 2,147,483,646 × 100 = 214,748,364,600입니다. int로 먼저 곱하면 2^32의 50배인 214,748,364,800을 빼고 남은 −200으로 넘쳐 버리고, 그 뒤에 long에 대입해도 원래 값을 되찾지 못합니다. 그래서 (long) (page - 1) * size처럼 곱하기 전에 long으로 바꿔 계산합니다. size=101은 상한을 넘으므로 offset 계산과 조회 전에 실패하고 조회는 0회입니다. 큰 수를 올바르게 계산했다고 해서 데이터베이스가 그 offset을 받아들이거나 빠르게 처리한다는 뜻은 아니므로, 최대 offset과 성능 제한은 이 산술 규칙과 따로 정합니다.

다음 글 프런트 컨트롤러 발전에서는 이 컨트롤러를 프런트 컨트롤러 하나가 고르고 실행하게 만들면서, URL 매핑과 공통 처리, 404를 나누어 다룹니다.