1. 도입부 (Introduction)
초기 웹 애플리케이션 개발 패러다임은 동적인 HTML 페이지를 빠르게 생성하는 것에 초점이 맞춰져 있었다. 자바 진영에서는 이를 위해 서블릿(Servlet)과 JSP(Java Server Pages)라는 강력한 무기를 제공했지만, 서비스의 규모가 커짐에 따라 단일 계층에 모든 로직이 집중되는 비대화 현상이 발생했다.
코드가 비대해질수록 가독성은 떨어지고 유지보수는 불가능에 가까워진다. 이러한 아키텍처적 위기를 극복하기 위해 도입된 것이 바로 MVC(Model-View-Controller) 패턴이다. 이번 글에서는 서블릿과 JSP가 가졌던 태생적 한계를 해부하고, MVC 패턴의 구조와 실제 자바 코드를 통한 적용법, 그리고 프레임워크 수준에서 해결해야 하는 순수 MVC 패턴의 한계점까지 상세히 정리해 본다.
[Spring] HttpServletRequest & HttpServletResponse 정리 : HTTP 메시지를 자바 객체로
1. 도입부 (Introduction)서블릿 컨테이너(톰캣)가 웹 브라우저의 HTTP 요청을 처리할 때 내부적으로 가장 먼저 수행하는 작업은 물리적 네트워크 스트림을 개발자가 다루기 쉬운 자바 객체로 포장하
myblog01150.tistory.com
2. 주요 특징 및 핵심 로직 (Main Features & Logic)
서블릿과 JSP의 구조적 한계
- 서블릿 중심 개발의 문제점: 서블릿은 자바 코드 내에 HTML 렌더링 문자열(writer.println("<html>..."))을 섞어 써야 하므로, 뷰 화면을 수정할 때 가독성이 극도로 떨어지고 복잡도가 증가한다.
- JSP 중심 개발의 문제점: HTML 작성은 편해졌으나, 비즈니스 로직(데이터베이스 조회 및 가공)과 서버사이드 연산이 JSP 한 파일 내에 공존하면서 JSP가 너무 많은 역할을 감당하게 된다.
MVC 패턴의 도입 배경과 핵심 설계 원칙

- 너무 많은 역할의 분산: 하나의 파일이 비즈니스 로직과 뷰 렌더링을 모두 처리하는 구조를 탈피하여, 시스템의 가독성과 유지보수성을 확보한다.
- 변경의 라이프 사이클(Life Cycle) 분리: UI(화면)를 일부 수정하는 주기와 비즈니스 로직(기능)을 수정하는 주기는 다르게 발생한다. 라이프 사이클이 다른 두 코드를 분리함으로써 상호 영향도를 최소화한다.
- 기능 특화(Specialization): JSP나 타임리프 같은 뷰 템플릿은 화면 렌더링에만 최적화되어 있으므로, 오직 뷰 생성 업무만 맡기는 것이 시스템 전체의 효율성을 극대화한다.
3. 상세 가이드 및 심층 분석 (Detailed Guide)
3.1 MVC 패턴의 구성 요소와 계층 분리 아키텍처
MVC 패턴은 애플리케이션의 책임을 크게 3가지 영역으로 명확히 구분한다.

- Controller (컨트롤러): HTTP 요청을 직접 받아서 클라이언트가 보낸 파라미터를 검증한다. 이후 핵심 비즈니스 로직을 실행한 뒤, 화면에 출력할 결과 데이터를 조회하여 모델(Model)에 적재하는 사다리 역할을 담당한다.
- Model (모델): 뷰(View)에 출력할 데이터를 임시로 보관하는 보관소다. 뷰는 비즈니스 로직이나 데이터베이스 저장 규격을 직접 알 필요 없이, 오직 모델에 담겨 있는 데이터만 바라보고 화면을 구성한다.
- View (뷰): 모델에 바인딩되어 있는 데이터를 사용하여 동적인 HTML 화면을 생성하는 데 전념한다.
실무 엔터프라이즈 환경에서는 컨트롤러 내부가 비대해지는 것을 막기 위해 비즈니스 로직을 전담하는 서비스(Service) 계층을 별도로 구축한다. 컨트롤러는 요청의 입출력만 제어하고, 실제 도메인 로직 처리는 서비스 계층을 호출하여 위임하는 구조를 취한다.

3.2 자바 서블릿 기반 MVC 패턴 실전 구현
자바 서블릿 표준 스펙과 JSP를 연동하여 실제 MVC 패턴을 어떻게 구현하는지 코드를 통해 살펴본다.
A. 회원 등록 폼 컨트롤러 (MvcMemberFormServlet)
사용자에게 회원 가입 입력을 받을 수 있는 단순 JSP 화면으로 연결해 주는 컨트롤러다.
package servlet.mvc;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
// /servlet-mvc/members/new-form 경로로 들어오는 HTTP 요청을 매핑한다.
@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 클라이언트에게 보여줄 최종 JSP 뷰 파일의 경로를 지정한다.
// /WEB-INF 디렉토리 내부에 위치한 파일은 외부 브라우저에서 URL로 직접 호출할 수 없다.
String viewPath = "/WEB-INF/views/new-form.jsp";
// 서블릿에서 JSP로 요청을 넘겨주기 위해 RequestDispatcher 객체를 생성한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
// dispatcher.forward()를 호출하여 서버 내부에서 지정된 JSP로 제어권을 넘긴다.
// 이 과정은 클라이언트(웹 브라우저)가 인지하지 못하는 서버 내부의 이동이다.
dispatcher.forward(request, response);
}
}
B. 회원 저장 컨트롤러 (MvcMemberSaveServlet)
요청 파라미터를 파싱하여 비즈니스 모델에 저장하고, 결과 데이터를 request 객체(Model)에 담아 결과 뷰로 넘기는 핵심 컨트롤러다.
package servlet.mvc;
import domain.Member;
import domain.MemberRepository;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {
// 비즈니스 데이터 처리를 전담하는 리포지토리 싱글톤 인스턴스를 확보한다.
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// HTTP 요청 객체로부터 클라이언트가 전송한 회원 이름과 나이를 파싱한다.
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
// 비즈니스 도메인 객체를 생성하고 저장소에 영속화한다.
Member member = new Member(username, age);
System.out.println("member = " + member);
memberRepository.save(member);
// HttpServletRequest 객체가 제공하는 임시 보관소를 Model로 활용한다.
// "member"라는 속성 이름(Key)으로 저장할 값(Value)인 member 객체를 세팅하여 뷰에 전달한다.
request.setAttribute("member", member);
// 저장이 완료된 후 결과를 출력해 줄 JSP 파일 경로를 설정하고 포워딩한다.
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
C. 회원 목록 조회 컨트롤러 (MvcMemberListServlet)
저장된 모든 회원 리스트를 리포지토리에서 끌어올려 컬렉션 데이터 형태로 모델에 바인딩하는 컨트롤러다
package servlet.mvc;
import domain.Member;
import domain.MemberRepository;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("MvcMemberListServlet.service");
// 저장소에 쌓여있는 모든 회원 데이터를 List 컬렉션 형태로 일괄 조회한다.
List<Member> members = memberRepository.findAll();
// 조회된 다건의 회원 데이터 리스트를 "members"라는 키로 request 모델 공간에 보관한다.
request.setAttribute("members", members);
// 회원 목록을 반복문을 통해 화면에 렌더링해 줄 전용 JSP 뷰 경로로 이동시킨다.
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
3.3 순수 MVC 패턴의 아키텍처적 한계점 해부
MVC 패턴의 적용을 통해 서블릿과 뷰 템플릿의 결합도는 성공적으로 낮췄으나, 프레임워크 기술의 도움 없이 서블릿 API만으로 웹 서비스를 구축할 경우 심각한 구조적 한계와 마주하게 된다.
① 포워드(Forward) 로직의 무한 중복
모든 서블릿 컨트롤러의 마지막 구문은 항상 아래와 같은 형식을 취한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
화면을 이동시켜야 하는 컨트롤러가 100개라면 이 보일러플레이트 코드가 100번 중복되어 전개된다. 자바 메서드나 유틸리티 클래스로 분리하려 해도, 서블릿 스펙 구조상 RequestDispatcher와 forward 메서드를 직접 다뤄야 하므로 코드 수준에서의 공통화가 매우 까다롭다.
② View Path(뷰 경로) 문자열의 중복 및 강결합
String viewPath = "/WEB-INF/views/new-form.jsp";
String viewPath = "/WEB-INF/views/save-result.jsp";
각 컨트롤러는 자신이 호출할 파일의 절대 경로 명사를 하드코딩하고 있다. 만약 확장자가 .jsp에서 .html로 바뀌거나 뷰가 저장되는 디렉토리 구조(views -> templates)가 변경되는 아키텍처적 개편이 발생하면, 하드코딩된 모든 서블릿 코드를 열어 수동으로 치환해야 하는 끔찍한 유지보수 비용이 발생한다.
③ 불필요한 서블릿 객체 의존성과 테스트 복잡도
단순히 화면만 열어주는 컨트롤러나, 응답 객체를 직접 제어하지 않는 대다수의 로직에서도 HttpServletRequest request, HttpServletResponse response를 상속 규격에 맞춰 무조건 선언해야 한다.
이러한 WAS 내장 객체들은 순수 자바 환경에서 가짜 객체(Mock)를 만들거나 독립적인 단위 테스트(Unit Test)를 수행하기에 진입 장벽이 매우 높다. 결국 컨트롤러 로직을 검증하기 위해 무거운 톰캣 서버를 매번 구동해야 하는 비효율성이 뒤따른다.
④ 공통 처리(Cross-Cutting Concern)의 전무함
웹 애플리케이션의 규모가 커지면 단순 비즈니스 로직 외에 전역적으로 처리해야 하는 공통 요구사항이 기하급수적으로 늘어난다.

- 클라이언트의 요청 파라미터 인코딩 처리 (request.setCharacterEncoding("UTF-8"))
- 특정 URL에 대한 로그인 인증 상태 및 세션 체크 검증
- 시스템 장애 추적을 위한 전역 로깅 및 글로벌 예외 처리
순수 서블릿 MVC 구조에서는 이러한 공통 로직을 각 서블릿 초입부에서 개별적으로 호출해야 한다. 일부 개발자가 실수로 특정 컨트롤러에서 인증 체크 유틸 메서드 호출을 누락하는 순간, 전체 시스템의 보안 구멍(Security Hole)으로 직결되는 심각한 리스크를 안고 있다.
4. 실무 팁 및 주의사항 (Tips & Notes)
- /WEB-INF 디렉토리의 비밀: 웹 애플리케이션 루트 하위의 /WEB-INF 폴더는 웹 컨테이너 표준 스펙상 외부 브라우저에서 절대 URL로 직접 파일을 탈취하거나 요청할 수 없다. 외부의 악의적인 접근을 차단하고, 무조건 컨트롤러라는 수문장을 거쳐 정제된 모델 데이터와 함께 화면을 반환하게 강제하는 것이 아키텍처 보안의 기본 상식이다.
- 중복 코드 제거의 실마리: 서블릿 단에서 발생하는 이러한 구조적 한계(포워딩 중복, 공통 처리 불가)를 애플리케이션 레벨에서 깔끔하게 해결하기 위해서는 구조를 완전히 뒤엎어야 한다. 개별 서블릿이 요청을 쪼개 받는 것이 아니라, 중앙에 단 하나의 문지기 서블릿을 두고 모든 클라이언트 요청을 중앙 집중형으로 통제하는 디자인 패턴을 설계해야 한다.
5. 마무리 (Conclusion)
서블릿과 JSP의 과도한 결합을 해결하기 위해 도입된 MVC 패턴은 관심사를 분리(Separation of Concerns)함으로써 소프트웨어의 가독성과 계층별 전문성을 획기적으로 향상시켰다.
그러나 서블릿 단위로 물리적 파일을 쪼개어 매핑하는 전통적인 웹 방식에서는 중복 코드의 양산과 전역 공통 처리의 부재라는 또 다른 아키텍처적 한계에 부딪히게 되었다. 이러한 구조적 문제를 근본적으로 해결하기 위해 현대적인 자바 웹 프레임워크들은 전면부에 거대한 중적 공통 컨트롤러를 배치하는 프론트 컨트롤러(Front Controller) 패턴을 핵심 인프라로 채택하고 있다. 스프링 MVC 프레임워크의 심장부인 DispatcherServlet이 바로 이 지독한 MVC의 한계를 극복하기 위해 탄생한 실무적 결정체다.