김영한 님의 '스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술'을 듣고 적은 글입니다.
먼저 Member, MemberRepository 클래스를 만들어준다. memberRepository는 싱글톤 패턴을 적용했기에 생성자를 private 접근자로 막아둔다.
package hello.servlet.domain.member;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class Member {
private Long id; //db에 저장하면 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 static 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(){ //싱글톤으로 할 거라 아무도 생성하지 못하게 생성자를 private로 막음
}
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()); //store에 있는 모든 arraylist의 값을 꺼내줌
}
public void clearStore(){
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;
import static org.assertj.core.api.Assertions.*; //static import
public class MemberRepositoryTest {
MemberRepository memberRepository = MemberRepository.getInstance();
@AfterEach
void afterEach(){ //각 테스트 끝날 때마다 실행됨
memberRepository.clearStore();;
}
@Test
void save(){
//given
Member member = new Member("hello", 20);
//when
Member savedMember = memberRepository.save(member);
//then
Member findMember = memberRepository.findById(savedMember.getId());
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
assertThat(result.size()).isEqualTo(2); //길이 확인
assertThat(result).contains(member1, member2); //result 안에 member1과 member2가 들어있는지 확인
}
}
@AfterEach를 사용해 각 테스트가 끝난 후 다음 테스에 영향을 주지 않도록 clearStore()를 호출해 초기화해준다.
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");
}
}
MemberRepository의 생성자는 private로 지정되어 있기에 외부에서 객체를 생성하지 못한다. 그렇기에 생성되어있는 객체를 꺼내오기 위해 .getInstance()를 사용해 꺼내온다.
단순한 회원 정보를 입력하는 HTML form을 만들어 응답한다. 일일히 자바 코드로 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;
@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>");
/*
w.write(" <tr>"); 이렇게 적게되면 동적이 아닌 정적으로 보여지는 거라 데이터에 따라 값이 바뀌지 않음
w.write(" <td>1</td>");
w.write(" <td>userA</td>");
w.write(" <td>10</td>");
w.write(" </tr>");
*/
for (Member member : members) { //동적으로 보여주기 위해 for문을 돌림
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>");
}
}
MemberListServlet은 memberRepository.findAll()을 통해 모든 회원을 조회하고 이를 for문을 통해 회원 수만큼 동적으로 생성하고 응답한다.
서블릿과 자바 코드만으로 HTML을 만드는 것은 복잡하고 비효율적이다. 자바코드로 HTML을 작성하는 것보다 HTML 문서에 동적으로 변경해야 하는 부분만 자바 코드를 넣을 수 있다면 더 편리할 것이다. 이것이 바로 템플릿 엔진이 나온 이유이다. 템플릿 엔진을 사용하면 HTML 문서에 필요한 곳만 코드를 적용해 동적으로 변경할 수 있다.
템플릿 엔진에는 JSP, Thymeleaf, Freemarker, Velocity 등이 있다
일단 JSP를 사용하려면 build.gradle에
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'jakarta.servlet:jakarta.servlet-api' //스프링부트 3.0 이상
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api' //스프링부트 3.0 이상
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl' //스프링부트 3.0 이상
//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>
첫 번째 줄은 JSP 문서라는 뜻이다. JSP문서는 이렇게 시작해야 한다.
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
//request, response는 그냥 사용 가능함
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>
</form>
</body>
</html>
회원 저장 서블릿 코드와 비슷하지만 회원저장 JSP는 HTML을 중심으로 하고, 자바 코드를 부분부분 입력해 주었다.
<%@ 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="/webapp/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을 만드는 작업이 자바 코드에 섞여 지저분하고 복잡했다. JSP를 사용한 덕분에 뷰를 생성하는 HTML작업을 깔끔하게 가져가고 중간중간 동적으로 변경이 필요한 부분만 자바 코드를 적용했다.
하지만 회원 저장 JSP를 보면 코드 상위 절반은 회원 저장을 위한 비즈니스 로직이고, 나머지 하위 절반만 결과를 보여주는 HTML이다. JAVA 코드, 데이터를 조회하는 리포지토리 등등 다양한 코드가 모두 JSP에 노출되어 있다. JSP가 너무 많은 역할을 한다.
그래서 등장한게 MVC 패턴이다.
MVC 패턴의 등장 이유 ?
컨트롤러 : HTTP 요청을 받아 파라미터를 검증하고, 비즈니스 로직을 수행한다. 그리고 뷰에 전달한 데이터를 조회해 모델에 담는다.
모델 : 뷰에 출력할 데이터에 담아둔다.
뷰 : 모델에 담겨있는 데이터를 사용해 화면을 그리는 일에 집중한다.
컨트롤러에 비즈니스 로직을 둘 수도 있지만 컨트롤러가 너무 많은 역할을 수행하기에 비즈니스 로직은 서비스라는 계층을 별도로 만들어 처리한다. 그리고 컨트롤러는 비즈니스 로직이 있는 서비스를 호출하는 역할을 담당한다.

MVC 패턴을 적용한 덕분에 컨트롤러의 역할과 뷰를 렌더링 하는 역할을 명확히 구분할 수 있게 되었지만 컨트롤러를 보면 중복이 많고, 필요하지 않는 코드들도 많이 보인다.
View로 이동하는 코드가 항상 중복 호출된다. 이 메서드를 공통화해도 되지만 해당 메서드도 항상 직접 호출해야 한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
String viewPath = "/WEB-INF/views/new-form.jsp";
만약 jsp가 아닌 thymleaf 같은 다른 뷰로 변경한다면 전체 코드를 다 변경해야 한다.
HttpServletRequest request, HttpServletResponse response
이 코드를 사용할 때도 있고, 사용하지 않을 때도 있다.
기능이 복잡해질수록 컨트롤러에서 공통으로 처리해야 하는 부분이 점점 더 많이 증가할 것이다. 단순히 공통 기능을 메서드로 뽑으면 될 것 같지만, 항상 해당 메서드를 호출해야 하고, 실수로 호출하지 않으면 큰 문제가 될 것이다. 그리고 항상 호출하는 것 자체도 중복이다.
이 문제를 해결하기 위해서는 컨트롤러 호출 전에 먼저 공통 기능을 처리해주는 수문장 역할이 있으면 좋을 것 같다. 그래서 프론트 컨트롤러 패턴을 도입하면 이런 문제를 깔끔하게 해결 가능하다.