1. 회원 관리 웹 애플리케이션 요구사항
회원 관리 웹의 회원 정보는 username, age 두 정보를 가진다. 또한 기능으로 회원 저장과 회원 목록 조회를 지원한다. 먼저 회원 클래스 아래와 같이 생성할 수 있다.
@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;
}
}
그리고 해당 Member를 저장하기 위한 Repository는 다음과 같이 생성했다.
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 clearStore() {
store.clear();
}
}
2. 서블릿으로 만들어보기
먼저 html form으로 데이터를 받아오기 위해 아래와 같이 html form을 생성해주는 서블릿 하나를 만들어준다.
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/newForm")
public class MemberFormServlet extends HttpServlet{
private MemberRepository memberRepository = MemberRepository.getInstance();
// http 응답으로 html을 보낼거임.
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
// servlet으로 하면 자바 코드로 html을 작성해야 하므로 굉장히 불편하다.
PrintWriter writer = response.getWriter();
writer.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");
}
}
- <form> 태그 안에 action에 있는 url을 보자. "/servlet/members/save"라고 되어 있다. 그럼 전송 버튼을 누르면 서버에 해당 url로 입력한 정보와 함께 요청을 보낸다.
그럼 "http://localhost:8080/servlet/members/newForm"으로 요청이 들어오면 아래와 같은 화면이 발생한다.
이제 "/servlet/member/save" 요청을 처리할 서블릿을 만들어보자. 해당 서블릿은 memberRepository에 전달된 유저의 정보를 저장하는 기능을 수행한다.
@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 {
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 writer = response.getWriter();
writer.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>");
}
}
만약 username을 nam, age를 22로 넘긴 경우 해당 서블릿은 아래와 같은 화면을 출력한다.
그리고 아래와 같이 지금까지 등록된 회원 목록을 조회해주는 서블릿을 추가로 만들어준다.
@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 writer = response.getWriter();
writer.write("<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) {
writer.write(" <tr>" +
" <td>" + member.getId() + "</td>" +
" <td>" + member.getUsername() + "</td>" +
" <td>" + member.getAge() + "</td>" +
" </tr>");
}
writer.write(" </tbody>" +
"</table>" +
"</body>" +
"</html>");
}
}
그러면 아래와 같이 지금까지 등록된 회원들을 한 번에 조회할 수 있다.
- 서블릿으로 자바 코드를 활용해 비지니스 로직을 만드는 것을 편하지만 HTML을 만드는 것이 너무 힘들다. 따라서 템플릿 엔진이라는 것을 보통 사용한다. 템플릿 엔진이란 HTML 엔진에 자바 코드를 넣는 것을 말한다. 위에서는 자바 코드에 HTML 코드를 넣은 것이고 그게 아니라 HTML 중간 중간에 자바 코드를 직접 넣는다. 이러한 템플릿 엔진에 JSP, Thymeleaf, Freemarker, Velocity 등이 있다.
3. JSP로 만들어보기
일단 JSP를 사용하기 위해 아래 코드를 build.gradle에 추가하자. (스프링부트 3.0 미만)
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl'
만약 스프링부트 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'
JSP로 servlet으로 작성한 save로직을 작성해보면 아래와 같이 작성할 수 있다.
<%@ page contentType="text/html;charset=UTF-8" language="java"%>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%
// 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>
<meta charset="UTF-8">
<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>
또한 회원 조회 로직도 아래와 같이 작성할 수 있다.
<%@ 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>
<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>
- <% ~ %>: 해당 태그를 사용해 자바 코드를 작성할 수 있다.
- <%= ~ %>: 자바 코드를 출력한다.
서블릿의 경우 View 화면을 위한 HTML을 만드는 작업이 자바 코드에 섞였다. 반면 JSP는 View를 생성하는 코드에 동적으로 변경이 필요한 부분에 자바 코드를 적용했다. 이 둘의 문제는 다음과 같다.
- 회원 저장 JSP를 보면 상위 절반은 회원 저장을 위한 비지니스 로직이고, 나머지 절반은 HTML로 보여주기 위한 View 영역이다. 회원 목록 조회도 마찬가지다. 코드를 보면 Java 코드, 데이터를 조회하는 레포지토리 등 다양한 코드가 노출된다. 즉, JSP가 너무 많은 역할을 한다. 이 때문에 유지보수도 힘들어진다.
- 이러한 문제를 해결하기 위해 MVC 패턴이 등장하여 비지니스 로직, View 역할을 따로 처리하게 된다.
4. MVC 패턴 - 개요
서블릿이나 JSP만으로 개발을 하면 비지니스 로직을 처리하는 부분, View를 처리하는 부분을 하나에서 처리해야 한다. 그렇기 때문에 여러 문제가 생긴다. 이 때문에 아래와 같은 문제가 생긴다.
- "많은 역할": 비지니스 로직, View 렌더링까지 모두 처리하면 너무 많은 역할을 맡게 된다. 따라서 유지보수가 어려워진다.
- "변경의 라이프 사이클": 설계에서 좋은 고민을 하려면 변경 주기가 다르면 분리해야 한다. 예를 들어, UI를 변경하는 일과 비지니스 로직을 변경하는 일은 큰 변경에서는 같이 일어나지만 그렇지 않고는 거의 따로따로 일어난다. 이렇게 변경의 라이프 사이클이 다를 때 하나의 코드로 관리하는 것은 유지보수에 좋지 않다.
- "기능 특화": JSP 같은 View Template은 화면을 렌더링하는데 최적화 되어있다. 서블릿은 자바 코드를 실행하는데 최적화 되어있다. 그렇기 때문에 본인들의 역할에 맡는 부분만 담당하는 것이 효과적이다.
이와 같은 문제로 Model/View/Controller라는 MVC 패턴이 등장한다. 지금까지 학습한 것처럼 하나의 서블릿이나 JSP로 처리하던 것을 Controller와 View의 영역으로 나눈다. 웹 어플리케이션은 보통 MVC 패턴을 사용한다. 각 부분의 의미는 다음과 같다.
- "Controller": HTTP 요청을 받아서 파라미터를 검증하고, 비지니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회하여 모델에 담는다.
- "Model": 뷰에 전달할 데이터를 담는다. 화면 구성에 필요한 데이터를 모두 모델에 담아서 전달해주기 때문에 뷰는 비지니스 로직이나 데이터 접근을 신경쓰지 않고, 화면 렌더링에 집중할 수 있다.
- "View": 모델에 담겨있는 데이터를 사용해 화면을 구성한다. 즉, HTML을 생성한다.
단, 컨트롤러에 비지니스 로직을 둘 수 있지만, 이 경우 컨트롤러가 너무 많은 일을 담당하게 된다. 따라서 비지니스 로직은 Service라는 계층을 따로 만들어 처리를 하고, 컨트롤러는 단지 비지니스 로직이 있는 서비스를 호출하는 역할을 담당한다. 즉, 정확히 말하면 아래 그림과 같이 작동한다.
추가적으로 MVC 패턴이 궁금하면 아래 게시물을 참고하자.
5. MVC 패턴 - 적용
지금부터 MVC 패턴을 적용해보자. View는 JSP, Controller는 서블릿, Model은 내부 저장소인 HttpServletRequest 객체를 사용할 것이다. request의 경우 request.setAttribute(), request.getAttribute()를 통해 데이터를 보관하거나 조회할 수 있다. 먼저 첫 번째 컨트롤러의 코드는 다음과 같다.
@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: controller에서 view로 이동할 때 사용함.
RequestDispatcher dispatcher= request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
- dispatcher.forward(): 다른 서블릿이나 JSP로 이동할 수 있다. 서버 내부에서 다시 호출이 발생한다. 즉, 요청에 대한 응답을 클라이언트로 보내주고 다시 클라이언트가 서버에 요청을 보내는 redirect와는 다르다. (포워드는 서버 내부에서 일어나는 호출이기 때문에 클라이언트는 인지하지 못한다.)
- 컨트롤러를 거쳐서 부를 수 있는 jsp 파일들은 WEB-INF 폴더 아래에 넣어주면 된다. 만약 해당 폴더 안에 없다면 .jsp파일을 브라우저를 통해 접근할 수 있지만 해당 폴더 안에 있는 .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>
그리고 저장 결과를 출력해주는 jsp를 불러올 controller는 다음과 같다.
@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);
}
}
그리고 해당 컨트롤러에서 불러오는 "save-result.jsp"는 다음과 같다.
<%@ page contentType="text/html;charset=UTF-8" language="java"%>
<html>
<head>
<meta charset="UTF-8">
<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>
- ${}: jsp에서 제공하는 표현식이다. 만약 이렇게 표현식을 사용하지 않으면 자바 코드를 사용해야 한다. 예를 들어, member.id의 경우 <%=((Member)request.getAttribute("member")).getAge()%>와 같이 작성해야 한다.
그리고 회원 조회 컨트롤러의 경우 아래와 같다.
@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);
}
}
그리고 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>
- jstl: 위 코드에서는 반복문을 실행해야 하는데 이러한 코드를 간편하고, 깔끔하게 작성하기 위해 jsp는 jstl이라는 기능을 제공한다. <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>를 선언하면 사용가능하다.
6. MVC 패턴 - 한계
위와 같이 MVC 패턴을 적용하면서 컨트롤러 역할, 뷰 렌더링 역할을 명확히 구분했다. 하지만 컨트롤러 부분에서 중복이 많고 필요하지 않은 코드도 많아보인다. 문제는 다음과 같다.
- 포워드 중복: View로 이동하는 코드가 중복되어 호출된다. 만약 메서드로 공통화해도 어쨋든 해당 메서드 자체도 직접 호출해야 한다.
- ViewPath 중복: 보면 주소의 앞에 "/WEB-INF/view/"가 꼭 붙고, 마지막에는 ".jsp"가 항상 붙는다. 만약 jsp가 아닌 thymeleaf같은 것으로 변경하려면 해당 코드들을 다 변경해야 한다.
- 사용되지 않는 코드: "HttpServletResponse response"의 경우 아예 사용되지 않는다.
- 공통 처리가 어렵다: 기능이 복잡할 수록 컨트롤러에서 공통으로 처리해야 하는 부분이 많아진다. 단순히 공통 기능을 메서드로 만들 수 있지만 어쨋든 해당 메서드를 항상 호출해야 한다. 실수로 호출하지 않으면 문제가 되고, 호출하는 것 자체도 중복이 된다.
정리하면 어쨋든 공통 처리가 어렵다는 것이 중요하다. 따라서 컨트롤러 호출 전에 먼저 공통 기능을 처리해야 한다. 이를 위해 "프론트 컨트롤러(Front Controller) 패턴"을 도입한다.
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring MVC-1] 스프링 MVC - 구조 이해 (0) | 2023.09.22 |
---|---|
[Spring MVC-1] MVC 프레임워크 만들기 (0) | 2023.09.19 |
[Spring MVC-1] 서블릿 (0) | 2023.09.09 |
[Spring MVC-1] 웹 어플리케이션 이해 (0) | 2023.09.06 |
[Spring] 빈 스코프 (0) | 2023.09.02 |