MVC 역할 분리
게시판 상세 화면을 컨트롤러의 입력·사용 사례, 모델의 표현 데이터, 뷰의 렌더링으로 나누고 각 계약을 Servlet 없이 독립 검증합니다.
MVC의 목적은 클래스 이름을 컨트롤러, Model, View로 바꾸는 데 있지 않습니다. 요청 해석과 사용 사례 호출, 화면에 필요한 데이터, HTML 렌더링의 변경 이유를 분리하는 데 있습니다. 컨트롤러가 응답 작성기를 잡거나 뷰가 리포지토리를 호출하면 이름만 MVC이고 결합은 그대로입니다.
컨트롤러의 입력 변환
작은 MVC 규칙을 Servlet API와 분리해 정의합니다.
package board.mvc;
import java.util.List;
import java.util.Map;
@FunctionalInterface
public interface PageController {
ModelView handle(Map<String, List<String>> parameters);
}package board.mvc;
import java.util.Map;
public record ModelView(
String viewName,
Map<String, Object> model) {
public ModelView {
model = Map.copyOf(model);
}
}컨트롤러는 원시 HttpServletRequest 대신 필요한 파라미터 맵을 받고 논리 뷰 이름과 불변 모델 스냅샷을 반환합니다.
이것이 완성형 프레임워크 API라는 뜻은 아닙니다.
요청 어댑터와 업무 컨트롤러의 경계를 관찰하기 위한 최소 규칙입니다.
상세 조회 컨트롤러
package board.web;
import java.util.List;
import java.util.Map;
import board.application.PostQuery;
import board.mvc.ModelView;
import board.mvc.PageController;
public final class PostDetailPageController
implements PageController {
private final PostQuery query;
public PostDetailPageController(
PostQuery query) {
this.query = query;
}
@Override
public ModelView handle(
Map<String, List<String>> parameters) {
var rawId = firstRequired(parameters, "id");
var post = query.required(
Long.parseLong(rawId));
var page = new PostPageModel(
post.id(),
post.title(),
post.contentLength(),
post.contentLength() >= 60);
return new ModelView(
"post-detail",
Map.of("post", page));
}
private static String firstRequired(
Map<String, List<String>> parameters,
String name) {
var values = parameters.get(name);
if (values == null || values.isEmpty()) {
throw new BadRequestException(
name + " is required");
}
return values.getFirst();
}
}컨트롤러는 JSP 경로와 HTML 태그를 모릅니다.
post-detail이라는 논리 이름을 반환하고, 긴 글 여부를 불리언으로 계산해 뷰가 글자 수 기준 60을 중복하지 않게 합니다.
찾을 수 없음과 유효하지 않은 ID는 프레임워크 예외 매핑에서 404·400으로 바꿉니다.
뷰의 모델 렌더링
Servlet 기반 뷰 어댑터는 모델을 속성으로 복사하고 안전한 내부 템플릿으로 포워드합니다.
package board.mvc;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
public final class JspView {
private final String path;
public JspView(String path) {
if (!path.startsWith("/WEB-INF/views/")) {
throw new IllegalArgumentException(
"view must be internal");
}
this.path = path;
}
public void render(
Map<String, Object> model,
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
model.forEach(request::setAttribute);
request.getRequestDispatcher(path)
.forward(request, response);
}
}뷰는 PostQuery를 주입받지 않고 이미 준비된 PostPageModel만 읽습니다.
템플릿 엔진으로 Thymeleaf를 쓰면 뷰 어댑터 구현은 달라지지만 컨트롤러의 논리 이름과 모델 규칙은 유지할 수 있습니다.
논리 뷰 이름을 파일 경로에 바로 이어 붙일 때 ../ 같은 경로 순회를 허용하지 않습니다.
등록된 뷰 이름 맵이나 엄격한 패턴으로 리졸버를 구성합니다.
컨트롤러·뷰 테스트
컨트롤러 테스트에는 Servlet 컨테이너나 템플릿 엔진이 필요 없습니다.
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class PostDetailPageControllerTest {
@Test
void post을_화면_model과_logical_view로_바꾼다() {
var query = new FixedPostQuery(
post(42L, "MVC separation", 80));
var controller =
new PostDetailPageController(query);
var result = controller.handle(
Map.of("id", List.of("42")));
assertThat(result.viewName())
.isEqualTo("post-detail");
assertThat(result.model())
.containsEntry(
"post",
new PostPageModel(
42L,
"MVC separation",
80,
true));
}
@Test
void id가_없으면_query를_호출하기_전에_실패한다() {
var query = new RecordingPostQuery();
var controller =
new PostDetailPageController(query);
assertThatThrownBy(() ->
controller.handle(Map.of()))
.isInstanceOf(BadRequestException.class)
.hasMessageContaining("id");
assertThat(query.invocations()).isZero();
}
}뷰 테스트는 모델 속성과 포워드 경로를 확인하고 실제 템플릿 테스트는 이스케이프된 HTML을 확인합니다. DB 통합 테스트는 리포지토리 어댑터를 검증합니다. 역할별 테스트가 같은 내용을 중복하지 않게 합니다.
PostDetailPageControllerTest
> post을_화면_model과_logical_view로_바꾼다() PASSED
PostDetailPageControllerTest
> id가_없으면_query를_호출하기_전에_실패한다() PASSED
query invocations on invalid input = 0
BUILD SUCCESSFULSpring MVC 책임 규칙
Spring MVC 컨트롤러에서는 인자 리졸버가 URI 변수를 변환하고 Model과 뷰 이름을 처리합니다.
package board.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
class PostPageController {
private final PostQuery query;
PostPageController(PostQuery query) {
this.query = query;
}
@GetMapping("/posts/{id}")
String detail(
@PathVariable long id,
Model model) {
var post = query.required(id);
model.addAttribute(
"post",
PostPageModel.from(post));
return "post-detail";
}
}직접 만든 파라미터 맵 파싱, 컨트롤러 조회, 모델 속성 복사, 뷰 리졸버를 Spring의 확장점이 담당합니다. 직접 프레임워크를 운영하려는 것이 아니라 DispatcherServlet 흐름을 이해하기 위해 작은 구현과 대응시키는 것입니다.
모델·도메인 분리
| 객체 | 소유 계층 | 포함할 값 |
|---|---|---|
Post | 도메인 | 업무 불변식과 저장 상태 |
PostResponse | API | 공개 JSON 스키마 |
PostPageModel | 페이지 | 표시할 텍스트·플래그·링크 정보 |
Model | MVC | 뷰 이름에 전달할 속성 |
하나의 거대 DTO를 JSON과 HTML, 영속성에 함께 쓰면 한 화면 필드가 데이터베이스 스키마까지 흔듭니다. 변환 코드가 조금 늘어도 각 경계의 호환성과 민감 정보 범위가 분명해집니다.
뷰 모델에 완성된 HTML 문자열을 넣기보다 텍스트와 의미 기반 플래그를 넣고 템플릿이 마크업을 선택합니다. 반대로 업무 계산을 템플릿 표현식에 길게 넣지 않습니다.
연습 문제
게시글 목록과 상세 컨트롤러를 PageController로 구현하고 공통 JspView를 사용하세요.
목록은 페이지네이션 파라미터를 검증해 모델에 페이지 메타데이터를 넣고, 상세는 없는 ID를 찾을 수 없음 예외로 반환합니다.
컨트롤러 테스트와 뷰 포워드 테스트를 분리합니다.
해설 보기
컨트롤러는 page와 size를 int로 변환하고 1 이상, 최대 크기 100을 검증한 뒤 쿼리를 호출합니다.
뷰 이름은 post-list로 고정합니다.
return new ModelView(
"post-list",
Map.of(
"posts", result.items()
.stream()
.map(PostListItem::from)
.toList(),
"page", new PageModel(
result.page(),
result.totalPages())));뷰 리졸버는 등록된 post-list -> /WEB-INF/views/post-list.jsp 매핑만 허용합니다.
사용자 파라미터를 뷰 이름으로 사용하지 않습니다.
목록 컨트롤러 테스트에서는 HTML을 비교하지 않고 쿼리 입력과 모델을 확인합니다.
다음 문서에서는 이 컨트롤러와 뷰 규칙을 모든 URL 앞의 프런트 컨트롤러 하나가 실행하게 만들고 매핑·공통 처리·404를 단계별로 옮깁니다.