본문으로 건너뛰기

안동민 개발노트

본문 시작

MVC 역할 분리

게시판 상세 화면을 컨트롤러의 입력·사용 사례, 모델의 표현 데이터, 뷰의 렌더링으로 나누고 각 계약을 Servlet 없이 독립 검증합니다.

MVC의 목적은 클래스 이름을 컨트롤러, Model, View로 바꾸는 데 있지 않습니다.

요청 해석과 사용 사례 호출, 화면에 필요한 데이터, HTML 렌더링의 변경 이유를 분리하는 데 있습니다.

컨트롤러가 응답 작성기를 잡거나 뷰가 리포지토리를 호출하면 이름만 MVC이고 결합은 그대로입니다.

HTML 다이어그램: /docs/spring/ch5/ch5-6/1.html

컨트롤러의 입력 변환

작은 MVC 규칙을 Servlet API와 분리해 정의합니다.

src/main/java/board/mvc/PageController.java
package board.mvc;

import java.util.List;
import java.util.Map;

@FunctionalInterface
public interface PageController {
    ModelView handle(Map<String, List<String>> parameters);
}
src/main/java/board/mvc/ModelView.java
package board.mvc;

import java.util.Map;

public record ModelView(
        String viewName,
        Map<String, Object> model) {
    public ModelView {
        model = Map.copyOf(model);
    }
}

컨트롤러는 원시 HttpServletRequest 대신 필요한 파라미터 맵을 받고 논리 뷰 이름과 불변 모델 스냅샷을 반환합니다.

이것이 완성형 프레임워크 API라는 뜻은 아닙니다.

요청 어댑터와 업무 컨트롤러의 경계를 관찰하기 위한 최소 규칙입니다.


상세 조회 컨트롤러

src/main/java/board/web/PostDetailPageController.java
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으로 바꿉니다.

HTML 다이어그램: /docs/spring/ch5/ch5-6/2.html

뷰의 모델 렌더링

Servlet 기반 뷰 어댑터는 모델을 속성으로 복사하고 안전한 내부 템플릿으로 포워드합니다.

src/main/java/board/mvc/JspView.java
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 컨테이너나 템플릿 엔진이 필요 없습니다.

src/test/java/board/web/PostDetailPageControllerTest.java
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 SUCCESSFUL
HTML 다이어그램: /docs/spring/ch5/ch5-6/3.html

Spring MVC 책임 규칙

Spring MVC 컨트롤러에서는 인자 리졸버가 URI 변수를 변환하고 Model과 뷰 이름을 처리합니다.

src/main/java/board/web/PostPageController.java
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도메인업무 불변식과 저장 상태
PostResponseAPI공개 JSON 스키마
PostPageModel페이지표시할 텍스트·플래그·링크 정보
ModelMVC뷰 이름에 전달할 속성

하나의 거대 DTO를 JSON과 HTML, 영속성에 함께 쓰면 한 화면 필드가 데이터베이스 스키마까지 흔듭니다.

변환 코드가 조금 늘어도 각 경계의 호환성과 민감 정보 범위가 분명해집니다.

뷰 모델에 완성된 HTML 문자열을 넣기보다 텍스트와 의미 기반 플래그를 넣고 템플릿이 마크업을 선택합니다.

반대로 업무 계산을 템플릿 표현식에 길게 넣지 않습니다.

HTML 다이어그램: /docs/spring/ch5/ch5-6/4.html

연습 문제

게시글 목록과 상세 컨트롤러를 PageController로 구현하고 공통 JspView를 사용하세요.

목록은 페이지네이션 파라미터를 검증해 모델에 페이지 메타데이터를 넣고, 상세는 없는 ID를 찾을 수 없음 예외로 반환합니다.

컨트롤러 테스트와 뷰 포워드 테스트를 분리합니다.

해설 보기

컨트롤러는 pagesizeint로 변환하고 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를 단계별로 옮깁니다.