이 글은 Spring MVC 구조를 이해하기 위해 직접 MVC 프레임워크를 만들어보는 과정을 담은 글이다. 프로젝트는 Spring boot v3.2.2, JDK 17, JSP, lombok을 이용하여 진행하였고, 인프런 김영한님의 스프링 MVC 1편 강의를 참고하여 작성하였다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
✔️ 간단한 회원 관리 웹 애플리케이션 만들기 (Java, JSP)
MVC 패턴을 점진적으로 적용시켜보기 위해 Java, JSP를 이용한 간단한 회원 관리 웹 애플리케이션을 먼저 만들어 볼 것이다. 회원 저장, 회원 조회 기능을 구현할 것이다. 먼저 회원 도메인 클래스(Member)와 회원 정보를 저장할 수 있는 회원 저장소(MemberRepository) 클래스를 만들 것이다.
[Member.java]
@Getter
@Setter
public class Member {
private Long id;
private String username;
private int age;
public Member() {
}
public Member(String username, int age) {
this.username = username;
this.age = age;
}
}
- 회원 도메인 클래스이다.
- id(고유번호)는 회원을 저장할 때 회원 저장소(MemberRepository)에서 할당한다.
[MemberRepository.java]
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
// singleton
private static final MemberRepository instance = new MemberRepository();
public static MemberRepository getInstance() {
return instance;
}
private MemberRepository() {
}
// 회원 저장
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
// 회원 고유번호로 조회
public Member findById(Long id) {
return store.get(id);
}
// 전체 회원 조회
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
- 회원 저장소는 싱글톤 패턴을 적용했다. 스프링을 사용하면 스프링 빈으로 등록하면 되지만, 지금은 최대한 스프링을 사용하지 않고 순수 Java, JSP, Servlet 만을 사용하는 게 목적이다.
- 싱글톤 패턴은 객체를 단 하나만 생성해서 공유한다. 생성자를 private 접근자로 막아둔다.
이제 JSP 파일을 작성해서 회원 저장 폼 화면, 회원 저장 성공 화면, 회원 조회 리스트 화면을 만들 것 이다.
[new-form.jsp]
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<form action="/jsp/members/save.jsp" method="post">
username: <input type="text" name="username"/>
age: <input type="text" name="age"/>
<button type="submit">전송</button>
</form>
</body>
</html>
- 회원 저장 폼 화면이다. 이름, 나이를 입력할 수 있다.
[save.jsp]
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%
MemberRepository memberRepository = MemberRepository.getInstance();
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
%>
<html>
<head>
<title>TITLE</title>
<meta charset="UTF-8">
</head>
<body> 성공
<ul>
<li>id=<%=member.getId()%></li>
<li>username=<%=member.getUsername()%></li>
<li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
- 회원 저장에 성공하면 보여줄 화면이다.
- 해당 회원의 id, username, age 값을 화면에 출력한다.
[members.jsp]
<%@ page import="java.util.List" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
MemberRepository memberRepository = MemberRepository.getInstance();
List<Member> members = memberRepository.findAll();
%>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
<thead>
<th>id</th>
<th>username</th>
<th>age</th>
</thead>
<tbody>
<%
for (Member member : members) {
out.write("<tr>");
out.write(" <td>" + member.getId() + "</td>");
out.write(" <td>" + member.getUsername() + "</td>");
out.write(" <td>" + member.getAge() + "</td>");
out.write("</tr>");
}
%>
</tbody>
</table>
</body>
</html>
- 전체 회원을 조회할 수 있는 페이지이다.
✔️ 위 JSP 코드의 문제점, 그리고 MVC 패턴의 등장
JSP를 이용하여 간단한 회원관리 웹 애플리케이션을 만들었다. 여기서 문제점을 찾을 수 있는데, 회원 저장(save.jsp) JSP를 보면 코드의 상위 절반은 회원을 저장하기 위한 비즈니스 로직이고, 나머지 절반 하위는 HTML로 보여주기 위한 뷰 영역이다. 회원 목록 JSP의 경우도 마찬가지이다. JSP가 너무 많은 일을 담당하고 있는 것이다. 추후에 프로젝트 크기가 아주 커진다면 유지보수는 지옥 그 자체 일 것이다.
이 문제를 어떻게 극복할까? 비즈니스 로직은 다른 곳에서 처리하고, JSP는 목적에 맞게 view화면만 그리는 일에 집중하도록 해보자. 과거 개발자들은 이러한 고민들을 우리보다 먼저 했다. 그래서 MVC 패턴이란 게 등장하게 된 것이다. 이제 위 JSP 코드를 MVC 패턴을 적용하여 리팩토링을 진행해 볼 것이다.
✔️ MVC 패턴 개요
MVC 패턴은 JSP가 처리하던 것을 컨트롤러(Controller)와 뷰(View)의 영역으로 역할을 나눈 것이다. 여기서 서비스, 리포지토리 영역을 볼 수 있는데, 컨트롤러에서 모든 비즈니스 로직을 처리하기엔 컨트롤러의 역할이 너무 커진다. 그렇기 때문에 서비스, 리포지토리 영역을 따로 분리하여 작성하는 게 좋다.
- 컨트롤러(Controller) : HTTP 요청을 받아 서비스(Service) 계층의 비즈니스 로직을 실행하는 역할을 한다. 뷰에 전달할 데이터를 모델에 담아준다.
- 서비스(Service) : 핵심 비즈니스 로직이 담겨있는 계층이다. 컨트롤러를 통해 핵심 비즈니스 로직을 실행한다.
- 모델(Model) : 뷰에 출력할 데이터를 담아두는 역할을 한다.
- 뷰(View) : 모델에서 전달받은 데이터를 이용해 화면을 그려주는 역할을 한다.
✔️ 회원 관리 웹 애플리케이션 MVC 패턴으로 리팩토링
서블릿(Servlet)을 컨트롤러로 사용할 것이고, JSP를 뷰(View)로 사용하여 MVC 패턴을 적용해볼 것이다. 모델(Model)은 HttpServletRequest 객체를 사용할 것이다. HttpServletRequest 객체는 내부에 데이터 저장소를 가지고 있으며, request.setAttribute(), request.getAttribute()를 사용하면 데이터 보관 및 조회가 가능하다.
이제 MVC 패턴을 적용하여 서블릿(Servlet)을 이용한 컨트롤러를 작성하고, 모델(Model)을 사용한 JSP를 새롭게 작성해 보자. 새로 작성한 서블릿 패키지와 JSP 디렉토리 구조는 아래와 같다.
📝 MVC 회원 저장 폼 컨트롤러 & 뷰
- URL : http://localhost:8080/servlet-mvc/members/new-form
[MvcMemberFormServlet.java] - 회원 저장 폼 컨트롤러
@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 {
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
- /WEB-INF : 이 경로에 JSP가 있으면 외부에서 직접 JSP를 호출하는 것이 불가능하다. 우리는 항상 컨트롤러를 통해서 JSP를 호출할 것이다.
- dispatcher.forward() : 다른 서블릿이나 JSP로 이동하는 기능이다. 서버 내부에서 다시 호출이 일어나기 때문에 클라이언트는 인지하지 못한다.
[new-form.jsp] - 회원 저장 뷰
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<!-- 상대경로 사용, [현재 URL이 속한 계층 경로 + /save] -->
<form action="save" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
📝 MVC 회원 저장 및 저장 결과
- URL : http://localhost:8080/servlet-mvc/members/new-form > 폼에 정보를 입력하고 submit 하면 회원이 저장되고 결과가 나온다.
[MvcMemberSaveServlet.java] - 회원 저장 컨트롤러
@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 {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
//Model에 데이터를 보관
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
- HttpServletRequest 객체를 모델(Model)로 이용하여 데이터를 보관한다.
- setAttribute()로 데이터를 보관하고 뷰(View)로 전달한다. 뷰(View)는 getAttribute()로 데이터를 꺼내 사용할 수 있다.
[save-result.jsp] - 회원 저장 결과 뷰
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
</head>
<body> 성공
<ul>
<li>id=${member.id}</li>
<li>username=${member.username}</li>
<li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
- JSP의 ${} 문법으로 request의 attribute에 담긴 데이터를 편리하게 조회할 수 있다.
📝 MVC 회원 전체 조회
- URL : http://localhost:8080/servlet-mvc/members
[MvcMemberListServlet.java] - 회원 전체 조회 컨트롤러
@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 {
List<Member> members = memberRepository.findAll();
request.setAttribute("members", members);
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
- List<Member>를 request 객체에 담아 뷰로 전달한다.
[members.jsp] - 회원 전체 조회 뷰
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
<thead>
<th>id</th>
<th>username</th>
<th>age</th>
</thead>
<tbody>
<c:forEach var="item" items="${members}">
<tr>
<td>${item.id}</td>
<td>${item.username}</td>
<td>${item.age}</td>
</tr>
</c:forEach>
</tbody>
</table>
</body>
</html>
✔️ MVC 패턴을 적용했지만, 보이는 문제점들
포워드 코드의 중복
Java, JSP로 만들었던 회원 관리 웹 애플리케이션에 MVC 패턴을 도입했다. 하지만 또 다른 문제점이 보이기 시작한다. 서블릿으로 구현한 컨트롤러에서는 아래와 같은 포워드 코드가 중복되고 있다. 메서드로 공통화해도 되지만, 항상 메서드를 직접 호출해줘야 한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
viewPath의 중복
또한 viewPath의 중복도 있다. 나중에 뷰 템플릿을 JSP가 아닌 타임리프(Thymeleaf)로 변경하려면 어떻게 할 것 인가? 모든 viewPath의 코드를 찾아 변경할 것인가? 너무 비효율적이다.
String viewPath = "/WEB-INF/views/save-result.jsp";
결과적으로 공통적인 처리 부분에 있어서 문제가 있다. 이 문제를 해결하기 위해서는, 컨트롤러가 호출되기 전에 공통적으로 처리하는 부분이 필요하다. 이 문제를 프론트 컨트롤러(Front Controller) 패턴을 도입하여 해결해보려고 한다. Spring MVC의 핵심은 이 프론트 컨트롤러에 담겨있다.
글이 너무 길어지는 것 같으니, 프론트 컨트롤러 패턴의 도입으로 중복 문제를 해결하는 것과, 점진적으로 발전해 가는 우리의 MVC 프레임워크는 다음 파트에서 이어서 작성해보려고 한다.
MVC 프레임워크 직접 만들기 Part 1 끝.