서블릿·JSP 책임
Servlet HTML 연결과 JSP scriptlet의 변경 결합을 재현하고 컨트롤러가 모델만 준비해 뷰에 포워드하는 경계와 이스케이프를 검증합니다.
Servlet 하나에서 파라미터를 읽고 리포지토리를 호출하고 HTML 문자열까지 쓰면 첫 화면은 빨리 나옵니다. 그러나 필드 하나를 추가할 때 Java 문자열, 이스케이프, 오류 처리, CSS 마크업이 함께 바뀝니다. 반대로 JSP에 SQL과 업무 분기를 넣으면 뷰가 요청 처리와 데이터 접근까지 소유합니다. 두 극단 모두 테스트와 변경 경계를 흐립니다.
서블릿의 표현 책임 혼합
다음 코드는 작동하지만 마크업과 사용자 입력이 같은 문자열 연결 안에 있습니다.
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
var id = Long.parseLong(
request.getParameter("id"));
var post = repository.findById(id)
.orElseThrow();
response.setContentType(
"text/html;charset=UTF-8");
response.getWriter().write(
"<html><body><h1>"
+ post.title()
+ "</h1><p>"
+ post.contentLength()
+ "자</p></body></html>");
}제목에 마크업이 들어오면 XSS가 되고, 디자이너가 클래스나 섹션을 바꾸려면 Java 소스를 편집해야 합니다. HTML 스냅샷을 읽기도 어렵고 따옴표 이스케이프 때문에 컴파일 오류가 표현 수정과 섞입니다.
뷰 템플릿은 HTML 구조를 HTML 문법으로 유지하고 컨트롤러는 어떤 데이터를 보여 줄지만 결정하게 합니다.
JSP와 스크립틀릿
JSP는 컨테이너가 Servlet으로 컴파일해 실행하는 서버 측 뷰 기술입니다. 표현식 언어와 태그 라이브러리로 모델을 출력할 수 있습니다. 문제는 JSP 자체가 아니라 스크립틀릿으로 요청 파싱, 리포지토리, 업무 규칙을 넣는 방식입니다.
<%
long id = Long.parseLong(request.getParameter("id"));
PostRepository repository =
(PostRepository) application.getAttribute("repository");
Post post = repository.findById(id).orElseThrow();
if (post.contentLength() >= 60) {
%>
<strong>긴 글</strong>
<%
}
%>뷰를 렌더링하려면 데이터베이스와 Servlet 컨텍스트 전역 속성이 필요하고, 트랜잭션·권한·찾을 수 없음 처리도 JSP 실행 중 발생합니다. HTML 일부가 이미 출력된 뒤 예외가 나면 상태를 바꾸기 어렵습니다.
표현 분기인 “긴 글 레이블을 보일지”는 컨트롤러가 불리언 뷰 모델로 준비하거나 템플릿의 단순 조건으로 둘 수 있습니다. “본문이 길면 추천 배지를 부여” 같은 업무 규칙은 애플리케이션·도메인에 남깁니다.
컨트롤러·뷰 분리
Model 2 구조에서는 Servlet이 입력과 사용 사례 호출을 담당하고 요청 속성에 뷰 모델을 넣은 뒤 내부 뷰로 포워드합니다.
package board.servlet;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public final class PostPageServlet extends HttpServlet {
private static final String VIEW =
"/WEB-INF/views/post-detail.jsp";
private final PostQuery query;
public PostPageServlet(PostQuery query) {
this.query = query;
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
var id = Long.parseLong(
request.getParameter("id"));
var post = query.required(id);
var model = new PostPageModel(
post.id(),
post.title(),
post.contentLength(),
post.contentLength() >= 60);
request.setAttribute("post", model);
request.getRequestDispatcher(VIEW)
.forward(request, response);
}
}/WEB-INF 아래 리소스는 브라우저가 직접 URL로 요청할 수 없고 컨트롤러 포워드를 통해서만 접근합니다.
사용자가 뷰 경로를 파라미터로 넘겨 임의 JSP를 실행하게 하지 않습니다.
JSP는 ${post.title} 같은 표현식 언어를 사용하되 출력 이스케이프 방식과 태그 라이브러리를 확인합니다.
Thymeleaf를 쓰는 게시판 본 프로젝트에서는 th:text가 텍스트 이스케이프를 담당합니다.
기술이 달라도 컨트롤러-모델-뷰 책임은 같습니다.
포워드 규칙 검증
쿼리 결과가 모델 속성으로 들어가고 지정한 내부 뷰로 포워드되는지 확인합니다.
package board.servlet;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
class PostPageServletTest {
@Test
void 조회한_post을_model로_만들어_view에_forward한다()
throws Exception {
var query = new FixedPostQuery(
post(42L, "Servlet MVC", 80));
var servlet = new PostPageServlet(query);
var context = new MockServletContext();
var request = new MockHttpServletRequest(
context, "GET", "/posts/detail");
request.addParameter("id", "42");
var response = new MockHttpServletResponse();
servlet.service(request, response);
assertThat(request.getAttribute("post"))
.isEqualTo(new PostPageModel(
42L, "Servlet MVC", 80, true));
assertThat(response.getForwardedUrl())
.isEqualTo(
"/WEB-INF/views/post-detail.jsp");
}
}이 테스트는 JSP HTML 결과까지 렌더링하지 않습니다. 컨트롤러의 조회와 모델·뷰 선택 계약만 확인합니다. 템플릿 렌더링 테스트에서는 실제 엔진과 이스케이프된 출력을 별도로 검증합니다.
PostPageServletTest
> 조회한_post을_model로_만들어_view에_forward한다() PASSED
model = PostPageModel[id=42, focused=true]
forward = /WEB-INF/views/post-detail.jsp
BUILD SUCCESSFUL리다이렉트와 포워드
포워드는 서버 내부에서 같은 요청·응답으로 다른 리소스에 처리를 넘깁니다. 브라우저 URI가 바뀌지 않고 요청 속성이 유지됩니다. 리다이렉트는 3xx 응답을 보내 브라우저가 새 요청을 만들며 URI가 바뀌고 기존 요청 속성은 사라집니다.
| 요구 | 포워드 | 리다이렉트 |
|---|---|---|
| 같은 조회 요청에서 뷰 렌더링 | 적합 | 추가 왕복 |
| POST 뒤 새로고침 방지 | 부적합 | 303 PRG 적합 |
| 요청 속성 모델 | 유지 | 유지되지 않음 |
| 브라우저 주소 표시줄 | 원래 URI | 새 위치 |
POST 성공 뒤 JSP로 포워드하면 주소 표시줄이 POST URI라 새로고침 중복이 생깁니다. 등록은 리다이렉트하고 조회 컨트롤러가 새 모델을 만드는 PRG를 사용합니다. 검증 실패에서는 같은 요청으로 폼 뷰를 렌더링해 입력과 오류를 보여 줄 수 있습니다.
뷰 모델 범위
JPA 엔티티나 JDBC 행을 그대로 뷰에 넘기면 지연 로딩, 내부 식별자, 민감 필드가 템플릿에서 우연히 노출될 수 있습니다.
PostPageModel은 화면에 필요한 값과 표현 상태만 담습니다.
뷰 모델을 지나치게 일반 Map<String,Object>로만 만들면 키 오타가 런타임까지 숨습니다.
작은 레코드로 타입을 주고 공통 레이아웃 메타데이터처럼 유연성이 필요한 일부에만 맵을 사용합니다.
포매팅 위치도 결정합니다. 로케일-민감한 날짜와 숫자는 뷰 계층 포매터가 담당하고, 업무 상태 계산은 애플리케이션에서 값으로 준비합니다. 템플릿에서 리포지토리를 호출하지 않습니다.
연습 문제
HTML 문자열을 직접 쓰는 게시글 목록 Servlet을 컨트롤러+뷰 구조로 바꾸세요.
컨트롤러는 PostListPageModel과 뷰 경로만 준비하고, 제목에 <script>가 들어가도 실제 렌더링된 HTML에서는 텍스트로 이스케이프되어야 합니다.
POST 등록 성공은 포워드가 아니라 303 리다이렉트여야 합니다.
해설 보기
조회 컨트롤러 테스트는 모델과 전달된 뷰를, 템플릿 테스트는 이스케이프된 출력을, 등록 테스트는 303과 위치를 각각 검증합니다. 한 테스트에서 DB부터 브라우저 DOM까지 모두 확인하지 않습니다.
request.setAttribute(
"model",
new PostListPageModel(
posts.stream()
.map(PostListItem::from)
.toList()));
request.getRequestDispatcher(
"/WEB-INF/views/post-list.jsp")
.forward(request, response);템플릿에는 데이터 조회나 요청 파라미터 파싱을 넣지 않습니다. 이스케이프를 끄는 원시 출력 기능은 신뢰된 정제기 결과처럼 명시적 타입과 근거가 있을 때만 사용합니다.
다음 문서에서는 컨트롤러·모델·뷰 분리를 하나의 작은 MVC 프레임워크로 만들고 요청마다 반복되는 매핑과 뷰 포워딩 코드를 공통 흐름으로 옮깁니다.