강의를 들으며 생각 정리
간단한 웹 애플리케이션 만들어보자. 서블릿 -> JSP -> MVC 패턴 순으로 애플리케이션을 개발하면서 웹 애플리케이션이 발전된 과정을 알아본다.
회원 관리 웹 애플리케이션 요구사항
<요구사항>
- 회원 정보
이름 : username
나이 : age
- 기능
회원 저장
회원 목록 조회
<Member>
@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는 Member를 회원 저장소에 저장하면서 할당한다.
<MemberRepository>
/**
* 동시성 문제가 고려되어 있지 않음, 실무에서는 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(){
}
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 clearStore() {
store.clear();
}
}
- 회원 저장소다. 데이터베이스를 따로 사용하지 않기 때문에 메모리에 저장하기 위해 HashMap을 사용한다. 또한 id 값을 위해 sequence 변수를 사용한다.
- 회원 저장소는 싱글톤 패턴을 적용했다. 스프링을 사용하면 스프링 빈으로 등록하면 되지만, 지금은 최대한 스프링 없이 순수 서블릿 만으로 구현하는 것이 목적이다. 싱글톤 패턴은 이전 포스팅을 참고하자.
- clearStore는 테스트용으로 제작해두었다.
<MemberRepositoryTest - 회원 저장소 테스트>
class MemberRepositoryTest {
MemberRepository memberRepository = MemberRepository.getInstance();
@AfterEach
void afterEach() {
memberRepository.clearStore();
}
@Test
public void save() throws Exception {
//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
public void findAll() throws Exception {
//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);
}
}
+) JUnit5부터 테스트 클래스에 public을 사용하지 않아도 된다.
회원을 저장하고, 목록을 조회하는 테스트를 작성했다. 테스트는 순서가 보장되지 않기 때문에 각 테스트가 끝날 때, 다음 테스트에 영향을 주지 않도록 각 테스트의 저장소를 초기화했다.
서블릿으로 회원 관리 웹 애플리케이션 만들기
먼저 서블릿으로 회원 관리 웹 애플리케이션을 만들어보자.
우선 서블릿으로 회원 등록 HTML 폼을 제공해보자.
<MemberFormServlet>
@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");
}
}
+) HTML로 응답할 때는 항상 Content-Type을 text/html로 설정해야 한다.
<결과>
이제 데이터를 POST로 전송했을 때 전달 받는 서블릿을 만들어보자.
<MemberSaveServlet>
@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>");
}
}
1. 파라미터를 조회해서(request) Member 객체를 만든다.
2. Member 객체를 MemberRepository를 통해서 저장한다.
3. Member 객체를 사용해서 결과 화면용 HTML을 동적으로 만들어서 응답한다.
<결과>
이번에는 저장된 모든 회원 목록을 조회하는 기능을 만들어보자.
<MemberListServlet>
@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>");
}
}
1. memberRepository.findAll()을 통해 모든 회원을 조회한다.
2. 회원 목록 HTML을 for 루프를 통해서 회원 수만큼 동적으로 생성하고 응답한다.
<결과>
지금까지 서블릿과 자바 코드만으로 HTML을 만들어보았다. 서블릿 덕분에 동적으로 원하는 HTML을 마음껏 만들 수 있다. 그런데, 코드에서 보듯이 자바로 HTML을 직접 코딩하는 것은 매우 복잡하고 비효율적이다. 차라리 HTML 문서에서 동적으로 변경해야 하는 부분만 자바 코드를 넣을 수 있다면 더 편리할 것이다.
이것이 바로 템플릿 엔진이 나온 이유이다. 템플릿 엔진을 사용하면 HTML 문서에서 필요한 곳만 코드를 동적으로 변경할 수 있다.
템플릿 엔진에는 JSP, Thymeleaf 등이 있다. 이제 JSP로 동일한 작업을 진행해보자.
JSP로 회원 관리 웹 애플리케이션 만들기
JSP를 사용하려면 먼저 다음 라이브러리를 추가해야 한다.
<build.gradle에 추가>
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl'
//JSP 추가 끝
서블릿과 마찬가지로 회원 등록 폼을 JSP로 작성해보자. 이 때, 기존 welcome page를 만들었던 webapp 폴더에 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>
<%@ page contentType="text/html;charset=UTF-8" language="java" %> -> 첫 줄은 JSP 문서라는 뜻이다. JSP 문서는 이렇게 시작해야 한다.
회원 등록 폼은 첫 줄을 제외하고 완전히 HTML과 똑같다. 기존에 만들었던 MemberFormServlet과 거의 비슷한 모습으로 변환된다.
<결과>
실행 시 .jsp까지 함께 적어주어야 한다.
<save.jsp - 회원 저장>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
//request, response 사용 가능
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>
</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는 자바 코드를 그대로 사용할 수 있다.
<? ~~ ?> : 이 부분에는 자바 코드를 입력할 수 있다. MemberSaveServlet의 회원을 저장하는 로직을 그대로 사용한다.
<?= ~~ ?> : 이 부분에는 자바 코드를 출력할 수 있다.
JSP의 HTML을 부분은 서블릿 코드와 같다. 다른 점이 있다면, JSP는 HTML을 중심으로 자바 코드를 부분부분 입력한다는 점이다.
<결과>
+) JSP도 결국 서버 내부에서 서블릿으로 변환되기 때문에 request, response 객체를 자동으로 사용할 수 있다. 그리고 아래 HTML 부분이 response에 자동으로 담기게 된다.
<members.jsp - 회원 목록>
<%@ 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>
MemberListServlet과 동일한 로직이다. 다만 JSP에서는 HTML을 작성할 때 필요한 부분(반복문)을 자바 코드로 작성한다. (out.write는 JSP에서 제공하는 기능이다.)
<결과>
<한계>
서블릿으로 개발할 때는 뷰(View)를 위한 HTML을 만드는 작업이 자바 코드에 섞여서 지저분하고 복잡했다. JSP를 사용한 덕분에 뷰를 생성하는 HTML 작업을 깔끔하게 가져가고, 중간중간 동적으로 변경이 필요한 부분에만 자바 코드를 적용했다. 하지만 아직 해결되지 않은 몇 가지 고민이 남는다.
앞서 작성한 JSP를 보면 코드의 절반은 비즈니스 로직이고, 절반은 결과를 HTML로 보여주기 위한 뷰 영역이다. 코드를 보면, 자바 코드, 리포지토리 등등 다양한 코드가 모두 JSP에 노출되어 있다. -> JSP가 너무 많은 역할을 한다.
만약 수백, 수천줄이 넘어가는 JSP를 떠올려보면 점점 코드가 복잡해질 것이다.
<MVC 패턴의 등장>
비즈니스 로직은 서블릿처럼 다른 곳에서 처리하고, JSP는 목적에 맞게 HTML로 뷰를 그리는 일에 집중한다. 지금부터 직접 MVC 패턴을 적용해서 프로젝트를 리팩토링 해보자.
MVC 패턴 - 개요
앞서 서블릿, JSP의 한계를 다시 한 번 검토해보자.
<너무 많은 역할>
하나의 서블릿이나 JSP만으로 비즈니스 로직과 뷰 렌더링까지 모두 처리하게 되면, 너무 많은 역할을 하게되고, 결과적으로 유지보수가 어려워진다.
<변경의 라이프 사이클>
이 부분이 중요한데, 진짜 문제는 둘 사이에 변경의 라이프 사이클이 다르다는 점이다. 예를 들어, UI를 일부 수정하는 일과 비즈니스 로직을 수정하는 일은 각각 다르게 발생할 가능성이 매우 높고 대부분 서로에게 영향을 주지 않는다. 이렇게 변경의 라이프 사이클이 다른 부분을 하나의 코드로 관리하는 것은 유지보수하기 좋지 않다.
<기능 특화>
특히 JSP 같은 뷰 템플릿은 화면을 렌더링 하는데 최적화 되어 있기 때문에 이 부분의 업무만 담당하는 것이 효과적이다.
<Model View Controller>
MVC 패턴은 하나의 서블릿, JSP로 처리하던 것을 컨트롤러, 뷰라는 영역으로 서로 역할을 나눈 것을 말한다. 웹 애플리케이션은 보통 이 MVC 패턴을 사용한다.
- 컨트롤러 : HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
- 모델 : 뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모델에 담아서 전달해주기 때문에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.
- 뷰 : 모델에 담겨있는 데이터를 사용해서 화면에 그리는 일에 집중한다.
+) 참고
컨트롤러에 비즈니스 로직을 둘 수도 있지만, 이렇게 되면 컨트롤러 역시 너무 많은 역할을 담당한다. 그래서 일반적으로 비즈니스 로직은 서비스(Service)라는 계층을 별도로 만들어서 처리한다. 컨트롤러는 비즈니스 로직이 있는 서비스를 호출하고 데이터를 모델에 담는 등 중앙 관리자 느낌으로 보면 좋다.
MVC 패턴 - 적용
서블릿을 컨트롤러로 사용하고, JSP를 뷰로 사용해서 MVC 패턴을 적용해보자. 모델은 request 객체를 사용한다. request는 내부에 데이터 저장소를 가지고 있는데, request.setAttribute(), request.getAttribute()를 사용하면 데이터를 보관, 조회할 수 있다.
<MvcMemberFormServlet>
@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);
}
}
- viewPath : 뷰(.jsp파일)로 사용할 파일의 경로다. /WEB-INF 경로 안에 JSP가 있으면 외부에서 직접 JSP를 호출할 수 없다. 사실
위 서블릿의 경우 비즈니스 로직이 전혀 없지만 항상 컨트롤러를 통해서 JSP를 호출하는 것을 기대하기 때문에 컨트롤러로서 서블릿을 만든다.
- dispatcher.forward() : 다른 서블릿이나 JSP로 이동할 수 있는 기능이다. 서버 내부에서 다시 호출이 발생한다.
+) redirect vs forward
리다이렉트는 실제 클라이언트에 응답이 나갔다가, 클라이언트가 redirect 경로로 다시 요청한다. 따라서 클라이언트가 인지할 수 있고, URL 경로도 실제로 변경된다.
반면에 포워드는 서버 내부에서 일어나는 호출이기 때문에 클라이언트가 전혀 인지하지 못한다.
<new-form.jsp - 회원 등록 폼>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<form action="save" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
여기서 폼의 action을 보면 절대 경로가 아니라 상대 경로로 설정했다. 이렇게 상대경로를 사용하면 폼 전송시 현재 URL이 속한 계층 경로 + save가 호출된다.
+) 이후 코드에서 해당 jsp를 계속 사용하기 때문에 상대경로로 설정했다.
<결과>
<MvcMemberSaveServlet - 회원 저장 컨트롤러>
@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);
}
}
일반적인 MemberSaveServlet과 비즈니스 로직은 같고 html 코드를 직접 넣은 것 대신에 request를 모델로 사용해서 뷰로 넘겨주는 작업을 추가했다.
request.setAttribute()를 사용하면 request 객체에 데이터를 보관해서 뷰에 전달할 수 있다.
<save-result.jsp>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</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>
request.getAttribute()로 모델에 저장한 member 객체를 꺼낼 수 있지만, 너무 복잡해진다.
JSP는 ${ } 문법을 제공하는데, 이 문법을 사용하면 request의 attribute에 담긴 데이터를 편리하게 조회할 수 있다.
+) member.username과 같은 표현 역시 JSP가 제공하는 표현법이다.
<결과>
<MvcMemberListServlet - 회원 목록 조회 컨트롤러>
@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);
}
}
request 객체를 사용해서 members를 모델에 보관했다.
<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>
기존에 반복문과 out.write를 사용해 테이블을 작성하는 방식 대신 JSP가 제공하는 taglib기능을 사용해서 출력하면 편리하다. -> members 리스트에서 member를 순서대로 꺼내서 item 변수에 담고, 출력하는 과정을 반복한다.
+) <c:forEach> 기능을 사용하려면 <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>을 선언해야 한다.
이처럼 JSP와 같은 뷰 템플릿은 이렇게 화면을 렌더링하는데 특화된 다양한 기능을 제공한다.
<결과>
+) 요즘은 뷰 템플릿으로 거의 타임리프를 사용하기 때문에 JSP는 간단하게만 알아둬도 좋다. 만약 JSP가 궁금하거나 레거시 프로젝트 등에서 JSP를 사용하는 경우 JSP로 검색하거나 관련된 책을 참고하면 금방 대부분의 기능을 학습할 수 있다.
MVC 덕분에 컨트롤러 로직과 뷰 로직을 확실하게 분리한 것을 확인할 수 있다. 향후 화면에 수정이 발생하면 뷰 로직만 변경하면 된다.
그러나 이 방식 역시 forward를 위한 로직을 자꾸 반복하는 등의 불편한 점이 있기 때문에 더 개선할 여지가 충분히 있다. MVC 패턴의 한계에 대해서 알아보자.
MVC 패턴 - 한계
MVC 패턴을 적용한 덕분에 컨트롤러와 뷰의 역할을 명확하게 구분할 수 있었다. 코드가 깔끔하고 직관적이다. 그런데 컨트롤러는 딱 봐도 중복이 많고, 필요하지 않는 코드들도 많이 보인다.
<MVC 컨트롤러의 단점>
- 포워드 중복
View로 이동하는 코드가 항상 중복 호출되어야 한다.
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
- ViewPath 중복
String viewPath = "/WEB-INF/views/new-form.jsp";
prefix: /WEB-INF/views/
suffix: .jsp
- 사용하지 않는 코드
다음 코드를 사용할 때도 있고, 사용하지 않을 때도 있다. 특히 response는 현재 코드에서 사용되지 않는다.
HttpServletRequest request, HttpServletResponse response
- 공통 처리가 어렵다
이 부분이 중요하다. 기능이 복잡해질수록 컨트롤러에서 공통으로 처리해야 하는 부분이 점점 더 많이 증가할 것이다. 이 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리해야 한다. 소위 수문장 역할을 하는 기능이 필요하다. 프론트 컨트롤러 패턴을 도입하면 이런 문제를 깔끔하게 해결할 수 있다.
스프링 MVC의 핵심도 바로 이 프론트 컨트롤러에 있다. 보통 프레임워크들은 프론트 컨트롤러를 잘 구현한 것이라고 봐도 된다. 이제부터 프론트 컨트롤러를 제공하는 프레임워크를 만들어보자.
'java > spring' 카테고리의 다른 글
[SpringMVC] 스프링 MVC - 구조 이해 (0) | 2021.04.26 |
---|---|
[SpringMVC] MVC 프레임워크 만들기 (0) | 2021.04.23 |
[SpringMVC] 서블릿 (0) | 2021.04.21 |
[SpringMVC] 웹 애플리케이션 이해 (0) | 2021.04.21 |
[Spring] devtools (0) | 2021.02.10 |