
이제 간단한 회원 관리 웹 애플리케이션을 만들어보자. 먼저 회원을 저장하고 조회하는 핵심 비즈니스 로직을 서블릿으로 만들어보고, 서블릿의 불편한 점을 살펴볼 것이다. 그리고 그 불편함을 개선하기 위해 JSP를 사용해보고, 또 JSP를 개선한 MVC 패턴도 살펴보도록 하자.
회원 도메인 모델과 메모리 기반의 회원 저장소 코드를 작성하자.
package hello.servlet.domain.member;
import lombok.Getter;
import lombok.Setter;
@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;
}
}
package hello.servlet.domain.member;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 동시성 문제가 고려되지 않기 때문에 실무에서는 ConcurrentHashMap, AtomicLong을 사용해야 할 수도 있다.
*/
public class MemberRepository {
private Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
// 싱글톤으로 생성
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());
}
public void clear() {
store.clear();
}
}
지금은 최대한 스프링 없이 순수 서블릿으로 구현하기 위해 회원 저장소에 싱글톤 패턴을 적용했다.
<테스트 코드>
package hello.servlet.domain.member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
class MemberRepositoryTest {
MemberRepository memberRepository = MemberRepository.getInstance();
@AfterEach
void afterEach() {
memberRepository.clear();
}
@Test
void save() {
// given
Member member = new Member("hello", 20);
// when
Member savedMember = memberRepository.save(member);
// then
Member findMember = memberRepository.findById(savedMember.getId());
Assertions.assertThat(findMember).isEqualTo(savedMember);
}
@Test
void findAll() {
// given
Member member1 = new Member("member1", 20);
Member member2 = new Member("member2", 30);
memberRepository.save(member1);
memberRepository.save(member2);
// when
List<Member> result = memberRepository.findAll();
// then
Assertions.assertThat(result.size()).isEqualTo(2);
Assertions.assertThat(result).contains(member1, member2);
}
}
회원을 저장하는 단위 테스트와 회원 목록을 조회하는 단위 테스트를 작성했다. 각 단위 테스트가 끝나고 다음 테스트에 영향이 가지 않도록 하기 위해 각 테스트의 저장소를 clear()를 호출해서 초기화 하도록 했다.
회원 가입을 해야 하니 HTML 폼을 제공해야 한다.
package hello.servlet.web.servlet;
import hello.servlet.domain.member.MemberRepository;
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.io.PrintWriter;
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
PrintWriter w = response.getWriter();
w.write("<!DOCTYPE html>\n" +
"<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>Title</title>\n" +
"</head>\n" +
"<body>\n" +
"<form action=\"/servlet/members/save\" method=\"post\">\n" +
" username: <input type=\"text\" name=\"username\" />\n" +
" age: <input type=\"text\" name=\"age\" />\n" +
" <button type=\"submit\">전송</button>\n" +
"</form>\n" +
"</body>\n" +
"</html>\n");
}
}
서블릿을 활용하면 이런 식으로 회원 정보를 입력할 수 있는 폼을 만들어서 응답한다. 코드를 작성하는 내내 짜증이 솟구친다… 이제 폼으로 데이터를 입력하고 전송을 누를 때 실제 회원 데이터가 저장되도록 해보자.
package hello.servlet.web.servlet;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
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.io.PrintWriter;
@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class MemberSaveServlet extends HttpServlet {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("MemberSaveServlet.service");
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
PrintWriter w = response.getWriter();
w.write("<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
"</head>\n" +
"<body>\n" +
"성공\n" +
"<ul>\n" +
" <li>id="+member.getId()+"</li>\n" +
" <li>username="+member.getUsername()+"</li>\n" +
" <li>age="+member.getAge()+"</li>\n" +
"</ul>\n" +
"<a href=\"/index.html\">메인</a>\n" +
"</body>\n" +
"</html>");
}
}
MemberSaveServlet은 먼저 파라미터를 조회해서 Member 객체를 만들고 MemberRepository에 저장시킨다. 그리고 Member 객체를 사용해서 HTML을 동적으로 만들어서 응답하는 것이다.
이제 저장소에 저장된 모든 회원들을 조회하는 기능을 만들어보자.
package hello.servlet.web.servlet;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
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.io.PrintWriter;
import java.util.List;
@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
List<Member> members = memberRepository.findAll();
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
PrintWriter w = response.getWriter();
w.write("<html>");
w.write("<head>");
w.write(" <meta charset=\"UTF-8\">");
w.write(" <title>Title</title>");
w.write("</head>");
w.write("<body>");
w.write("<a href=\"/index.html\">메인</a>");
w.write("<table>");
w.write(" <thead>");
w.write(" <th>id</th>");
w.write(" <th>username</th>");
w.write(" <th>age</th>");
w.write(" </thead>");
w.write(" <tbody>");
for (Member member : members) {
w.write(" <tr>");
w.write(" <td>" + member.getId() + "</td>");
w.write(" <td>" + member.getUsername() + "</td>");
w.write(" <td>" + member.getAge() + "</td>");
w.write(" </tr>");
}
w.write(" </tbody>");
w.write("</table>");
w.write("</body>");
w.write("</html>");
}
}
하… 진짜 못 할 짓이다. 너무 복잡하고 비효율적이다. 이런 식으로 일일이 자바 코드를 쳐서 HTML을 만드는 것보다 차라리 HTML 문서에 동적으로 변경해야 하는 부분만 자바 코드로 작성하면 더 좋지 않을까? 여기서 템플릿 엔진이 등장하는 것이다. 템플릿 엔진을 사용하면 HTML 문서에서 필요한 곳에만 자바 코드를 적용해서 동적으로 변경할 수 있다. 템플릿 엔진에는 JSP, Thymeleaf 등이 있다.
먼저 JSP를 사용하려면 의존성 몇 가지를 추가해야 한다. 스프링 부트 3.0 이상의 프로젝트에서는 아래 의존성을 추가해주도록 하자.
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'jakarta.servlet:jakarta.servlet-api'
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api'
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl'
의존성 적용을 마치면 바로 회원 등록 폼을 추가해보자.
<%@ 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>
<%@ page contentType="text/html;charset=UTF-8" language="java" %> 줄이 바로 JSP 문서라는 뜻이다. 보다시피 첫 줄을 제외하고는 HTML과 똑같다. JSP는 서버 내부에서 서블릿으로 변환되는데, 아까 만들었던 MemberFormServlet과 거의 비슷한 모습으로 변환된다.
이제 회원 저장 JSP를 작성해보자.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%
MemberRepository memberRepository = MemberRepository.getInstance();
System.out.println("MemberSaveServlet.service");
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>
</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>
전체적으로 회원 저장 서블릿과 비슷한데, 중간 중간 필요한 부분에만 자바 코드가 살짝 들어간 것을 확인할 수 있다.
바로 회원 목록 JSP를 작성하자.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page import="java.util.List" %>
<%
MemberRepository memberRepository = MemberRepository.getInstance();
List<Member> members = memberRepository.findAll();
%>
<html>
<head>
<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>
회원 저장소를 먼저 조회하고, 결과로 받은 리스트 안의 회원들을 HTML로 뽑아내도록 했다.
서블릿으로 개발하면 뷰 화면을 위한 HTML을 만드는 작업이 자바 코드랑 섞여 있어서 복잡하고 비효율적이다. 하지만 JSP를 사용하게 되면 뷰를 생성하는 HTML 작업을 깔끔하게 가져갈 수 있고, 동적으로 변경이 일어나는 부분들만 조금씩 자바 코드를 적용할 수 있었다.
하지만 JSP 파일을 보면, 코드의 상위 절반은 비즈니스 로직이 들어있고, 나머지 하위 절반에는 HTML로 보여주기 위한 뷰 영역이 존재한다. 너무나 다양한 코드를 하나의 파일에 다 때려 박은 느낌이다. 지금도 이 정도인데, 실무에 가면 코드 양이…?
그래서 MVC 패턴이 등장하게 된 것이다. 비즈니스 로직은 서블릿처럼 다른 곳에서 처리하도록 하고, JSP는 목적에 맞게 HTML로 화면을 그리는 일에 집중하게 하는 것이다.
서블릿과 JSP를 사용했을 때의 진짜 중요한 문제점은 “둘 사이에 변경의 라이프 사이클이 다르다” 는 점이다. 비즈니스 로직을 수정하는 일과 UI를 수정하는 일은 다르게 발생할 가능성이 높고, 대부분 서로에게 영향을 주지 않을 것이다. 이런 식으로 라이프 사이클이 다른 부분들을 하나의 파일에서 관리하는 것은 유지보수 관점에서 좋지 않다.
그래서 MVC 패턴은 하나의 서블릿이나, JSP로 처리하던 것을 컨트롤러(Controller)와 뷰(View)라는 영역으로 서로 역할을 나눈다.
컨트롤러(Controller): HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 모델에 담는다.
모델: 뷰에 출력할 데이터를 담아두는 공간이다. 뷰에 필요한 데이터를 모두 모델에 담아서 전달해주기 때문에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 더 집중할 수 있다.
뷰: 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 여기서는 HTML을 생성하는 부분을 말한다.
그림을 보면서 이해해보자.

클라이언트가 요청을 하면 컨트롤러에서 비즈니스 로직을 다 수행한다. 그 다음에 모델이라는 바구니에 그 데이터를 담아주고, 뷰 로직으로 제어권을 넘긴다. JSP가 그때부터 실행되면서 모델에 있는 데이터를 참조해서 뷰를 쫙 그려서 응답이 나가는 것이다. 컨트롤러가 서블릿이고, 뷰가 JSP인 것이다. 위 그림은 컨트롤러를 쉽게 설명하기 위해 그냥 비즈니스 로직이라고 했지만, 보통 비즈니스 로직이라 함은 아래 그림 구조와 같이 처리한다.

예를 들어 회원을 저장하는 로직, 주문 로직 같은 비즈니스 로직들은 보통 서비스(Service)라는 클래스에 작성된다. 그리고 서비스가 리포지토리를 사용하는 것이다.
전체적인 흐름을 정리해보면...
클라이언트가 컨트롤러를 호출하면, 컨트롤러는 request 파라미터를 꺼내서 HTTP 요청이 제대로 맞는지 스펙을 확인한다.
컨트롤러가 서비스나 리포지토리 등을 호출해서 데이터를 저장하거나 주문을 하는 등의 로직들을 실행하고 결과를 받는다.
그리고 컨트롤러는 그 결과를 모델에 전달한다. 동시에 뷰 로직으로 제어권을 넘겨서 뷰가 모델에서 값을 꺼내 쭉 출력해줄 수 있도록 한다.
MVC 패턴을 적용할 때, 서블릿은 컨트롤러, JSP는 뷰 역할을 맡는다. 그렇다면 모델은 무엇일까?
HttpServletRequest 객체에는 요청과 함께 데이터를 담을 수 있는 임시 저장소(attribute 공간)가 있다고 했다. 바로 이 저장소를 모델처럼 사용하는 것이다. 컨트롤러에서는 request.setAttribute()를 통해 데이터를 담고, 뷰(JSP)에서는 request.getAttribute() 또는 EL(Expression Language)을 이용해 해당 데이터를 꺼내 쓴다. 결국 모델은 뷰에서 표현할 데이터를 담아 전달하는 역할을 하고, HttpServletRequest의 파라미터가 그 전달 매체로 활용되는 것이다.
그럼 이제 회원 등록 폼 컨트롤러를 먼저 작성해보자.
package hello.servlet.web.servletmvc;
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 = "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);
}
}
request.getRequestDispatcher(viewPath)는 컨트롤러에서 이동할 뷰(JSP)의 경로를 지정할 때 사용된다. 그 후 dispatcher.forward(request, response)를 호출하면, 클라이언트에게 다시 요청을 보내는 것이 아니라 서버 내부에서 지정한 JSP로 제어를 넘겨준다.
즉, 클라이언트가 /servlet-mvc/members/new-form으로 요청을 보내면 서블릿의 service()가 실행되고, 여기서 forward()를 통해 "/WEB-INF/views/new-form.jsp" 가 호출된다. 이 과정은 서버 내부에서 일어나므로, 클라이언트의 브라우저 주소창은 그대로 유지되고, JSP가 최종 응답을 만들어 클라이언트에게 전달된다.
이제 WEB-INF 디렉터리 내부에 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>
그리고 회원 저장 컨트롤러를 작성하자.
package hello.servlet.web.servletmvc;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.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 {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
// 모델에 데이터를 보관
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
아까 말했다시피 HttpServletRequest를 모델로 사용하는 것이다. 코드를 보면 request.setAttribute()를 사용해서 request 객체에 데이터를 담아서 뷰로 전달하고 있는 것을 볼 수 있다.
아래의 뷰는 그냥 request.getAttribute()로 데이터를 꺼내기만 하면 되는 것이다.
<%@ 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>
다음으로 회원 목록 조회 컨트롤러와 뷰를 빠르게 구현해보자.
package hello.servlet.web.servletmvc;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.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 {
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);
}
}
<%@ 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 패턴을 적용함으로써 컨트롤러의 역할과 뷰를 렌더링하는 역할을 명확하게 구분할 수 있었지만, 컨트롤러 코드를 보면 중복도 많고, 불필요한 코드도 많아 보인다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
보다시피 뷰로 이동하는 코드는 항상 중복되어 있다. 메서드로 뽑는 방법도 있겠지만, 그래도 그 메서드 호출은 항상 해줘야 할 것이다. 이런 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리해야 하는 수문장 역할이 필요하다. 이때 프론트 컨트롤러(Front Controller) 패턴을 도입하면 된다. 스프링 MVC의 핵심은 바로 여기에 있다고 할 수 있다.