Spring MVC : 스프링 MVC 단계별 구현
- Spring/Spring MVC
- 2021. 12. 12.
이번 포스팅에서는 스프링 MVC 프레임워크를 단계별로 구현해보고자 한다. 먼저, 비즈니스 요구 사항에 맞게 필요한 클래스들을 만든 후, 총 다섯 번에 걸쳐서 리팩토링이 될 예정이다.
비즈니스 요구 사항(회원 관리 시스템)
- Member 클래스는 username, age 필드를 가진다.
- MemberRepository에 Member를 저장한다. 이 Repository는 싱글톤으로 관리된다.
@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 클래스의 코드다.
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static Long sequence = 0L;
private static MemberRepository instance = new MemberRepository();
private MemberRepository() {}
public MemberRepository getInstance(){
return instance;
}
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<Member>(store.values());
}
public static Map<Long, Member> getStore() {
return store;
}
public static void setStore(Map<Long, Member> store) {
MemberRepository.store = store;
}
public static Long getSequence() {
return sequence;
}
public static void setSequence(Long sequence) {
MemberRepository.sequence = sequence;
}
}
위는 MemberRepository의 코드다
Member, MemberRepository 정상 동작 테스트 코드
public class MemberRepositoryTest {
MemberRepository memberRepository = MemberRepository.getInstance();
@AfterEach
public void afterEach(){
memberRepository.clearStore();
}
@Test
public void findById(){
//given
Member member = new Member("member", 20);
//when
Member findMember = memberRepository.save(member);
//then
assertThat(memberRepository.findById(findMember.getId())).isEqualTo(member);
}
@Test
public void findAll() {
//given
Member memberA = new Member("memberA", 20);
Member memberB = new Member("memberB", 20);
memberRepository.save(memberA);
memberRepository.save(memberB);
//when
List<Member> members = memberRepository.findAll();
//then
assertThat(members.size()).isEqualTo(2);
}
}
위 테스트 코드로 member와 MemberRepsitory가 정상으로 동작하는지를 확인한다.
서블릿으로 회원관리 웹 어플리케이션 만들기.
아래 Member와 MemberRepository를 활용해 서블릿을 이용한 회원관리 웹 어플리케이션을 만들고자 한다. 특정 URI에 HTTP 요청이 오면 해당되는 서블릿이 실행된다. 그리고 그 서블릿의 로직이 실행되면서 값이 저장되고, 필요하면 응답을 주는 식으로 만들어질 예정이다.
MemberFormServlet → HTML FORM으로 데이터 입력받기
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {
@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");
}
}
MemberFormServlet를 만들었다. 이 클래스는 "/servlet/members/new-form" URI로 접근하면, 이 서블릿이 실행된다. 서블릿이 실행되면 , Service 메서드가 자동으로 실행된다. 이 서블릿에서는 입력을 받기 위해 HTML-FORM은 응답으로 내려주면 된다. 따라서 request 객체를 이용한 비즈니스 로직을 구현하는 것은 없다.
대신 Response로 열심히 꾸며줘야한다. 위의 코드를 살펴보면 하나 뜨악하는 것을 볼 수 있다. HTML을 JAVA 코드로 표현을 해야한다. 그런데 문제는 HTML을 JAVA 코드로 표현하다보니, String으로 모두 표현된다. 따라서, HTML에 오타가 발생할 경우 찾을 길이 없다. 어찌됐건 위의 값을 실행하면 다음과 같은 화면이 뜬다.
"<form action=\"/servlet/members/save\" method=\"post\">\n" +
위 HTML FORM의 코드를 보면 /servlet/members/save로 POST 메서드로 접근한다는 것을 볼 수 있다. 즉, 이 URI에 대응되는 서블릿을 만들어야 한다.
MemberSaveServlet → HTML FORM 데이터를 Repository에 저장하기
@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 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을 만든다. 이 서블릿에서는 MemberRepository에 저장을 해야 되기 때문에 미리 객체를 하나 불러와준다. 여기서는 HTML-FORM에서 보내준 요청 데이터를 참고할 필요가 있다.
Request에서 먼저 데이터를 읽어온다. username, age 파라메터로 검색해서 각각을 변수에 저장을 해둔다. 그리고 그 변수를 바탕으로 Member를 생성하고, MemberRepository에 저장을 한다. 이렇게 되면 현재 요청에 대한 멤버는 잘 저장이 되었다.
Response쪽도 살펴봐야한다. Response는 사실 아무런 일을 하지 않아도 된다. 그렇지만 어떤 멤버가 저장되었는지, 그리고 웰컴 페이지로 돌아갈 수 있도록 편의성으로 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>");
}
}
위는 저장된 회원 목록을 하나씩 출력하기 위한 서블릿이다. 먼저 members라는 Colletction을 불러온다. 그리고 이 members를 동적으로 출력하는 HTML FORM을 작성한다.
회원 목록을 확인하기 위해서는 request에서는 따로 무언가를 할 필요는 없다. 그렇지만 기존 DB에서 값을 찾아와야하기 때문에 memberRepository.findAll() 메서드 실행이 필요하다. 이렇게 되면 members 콜렉션을 가져올 수 있다.
response에서는 찾아온 콜렉션을 바탕으로 HTML을 내려줘야한다. 따라서 자바 코드로 다시 한번 쌩노가다로 HTML 코드를 작성해서 내려준다. 실행 결과는 위에서 확인할 수 있다.
서블릿으로 웹 어플리케이션 구현 결과
- 서블릿 하나에서 비즈니스 로직과 HTML 렌더링까지 다 한다.
- 자바 코드로 HTML을 표현해야한다. 문자열이기 때문에 디버깅이 사실상 불가능하다.
서블릿만으로 웹 어플리케이션을 구현하게 되면 위의 두 가지 상황을 알 수 있다. 1번은 그렇다치더라도, 2번은 현재 상황에서는 아주 치명적이다. 왜냐하면 1번은 프로그램이 돌아가기는 하지만, 2번은 디버깅이 어려워 프로그램이 안 돌아갈 가능성이 있기 때문이다.
2번을 위한 해결책이 있다. 바로 JSP다. 서블릿에서는 자바 코드로 HTML을 짜지만, JSP를 기반으로 한다면 HTML에 자바 코드를 넣을 수 있다. 즉, HTML에 대한 디버깅이 가능하게 된다.
JSP를 이용한 웹 어플리케이션 만들기
위에서는 서블릿을 활용해서 비즈니스 로직을 구현하고, HTML 랜더링까지 해서 클라이언트에 반환해주는 일을 했다. 이 때, 비즈니스 로직은 그럭저럭 짤 수 있었다. 그렇지만 자바코드로 HTML을 랜더링하는 일이 매우 어려웠다. 왜냐하면 디버깅이 안되었기 때문이다. 그래서 이번에는 JSP를 통해 비즈니스 로직을 짜고, HTML 랜더링을 하는 하도록 해본다.
JSP 라이브러리 추가
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'javax.servlet:jstl'
//JSP 추가 끝
JSP 및 JSTL을 사용하기 위해서 먼저 위의 라이브러리를 추가해야한다. 이 라이브러리를 build.gradle의 dependencies에 추가한 다음에 새로고침을 해주면 사용할 준비가 완료된다.
먼저 webapp/jsp/members로 디렉토리를 만든다. 그리고 각 URI에 대응될 수 있도록, member.jsp / new-form.jsp / save.jsp를 파일로 만들어준다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
각 파일의 가장 앞쪽에는 위 코드를 작성해준다. 위 코드는 인텔리제이에게 이 파일은 JSP 형식이라는 것을 알려주는 것이다.
// new-form.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<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>
위는 new-form.jsp의 코드파일이다. new-form.jsp에는 비즈니스 로직이 없다. 왜냐하면 HTML-form을 랜더링해서 클라이언트에게 내려주고, 클라이언트는 그 HTML-FORM에 값을 입력해주고 Submit을 눌러주는 형식으로 동작된다. Submit을 눌러주면 이 때 username, age input으로 값이 들어온다.
action은 데이터가 들어왔을 때 값을 보내줄 url을 지정하는 것이다. jsp 파일로 직접 접근하는 것이기 때문에 실제로 저장된 경로를 확인해서 jsp를 지정해준다. JSP 파일은 HTML FORM을 만들어서 랜더링 해주고, 클라이언트가 여기에 값을 넣어서 제출하면, save는 URL로 해당 내용은 POST 해준다. 서버를 실행하면 위처럼 정상적으로 뜨는 것을 볼 수 있다.
save.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hellomvc.servlet.domain.member.Member" %>
<%@ page import="hellomvc.servlet.domain.member.MemberRepository" %>
<%
//request, response는 그냥 사용이 가능함.
//jsp도 서블릿으로 바뀌기 때문임.
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>
save.jsp 파일을 만든 후, 위의 코드를 작성한다. save.jsp에는 이제 비즈니스 로직 + HTML 랜더링이 함께 들어가게 된다. JSP 파일에서는 <% ~ %> 사이에 자바 코드를 작성할 수 있다. 인텔리제이 무료 버전에서는 편의 기능을 지원하진 않지만, JSP 파일을 작성 후 돌리면 문제 없이 돌아간다.
<% ~~ %>
자바 코드 입력 가능
<%= ~ %>
자바 코드 출력 가능
먼저 "MemberSaveServlet"의 자바 비즈니스 로직 코드를 모조리 가지고 와서 복사 붙여넣기를 한다. 그렇게 해도 정상적으로 돌아가기 때문이다. 그리고 HTML 랜더링을 하면 된다. HTML을 랜더링 할 때는 <%= ~ %>를 이용해서 자바 객체에서 가져온 값을 하나씩 출력하고 있다. 즉, 어떤 객체가 오는지에 따라 동적으로 출력을 하고 있는 상황이다.
위의 코드를 작성한 후, 서버를 동작시켜서 save.jsp를 실제로 사용해봤다. 실행하면 Member가 저장된 후, 값이 MemberRepository에 저장되고, 그 결과를 HTML로 랜더링해서 위처럼 보여준다.
//members.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hellomvc.servlet.domain.member.Member" %>
<%@ page import="hellomvc.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>
<%
// 여기는 프로그램을 루프로 돌리고 잇음.
// out 그냥 쓸 수 있는 예약어라고 함.
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>
members.jsp를 가지고 왔다. member.jsp에는 JSTL 기능을 사용했는데, JSTL은 인텔리제이에서 Iterator를 사용한 것처럼 For문으로 출력해주는 기능이 있다고 한다. 여기서는 out이라는 예약어가 있는데, 이 예약어를 사용해서 loop문을 돌렸다.
먼저 jsp의 위에서는 기본적인 비즈니스 로직을 자바 코드로 실행했다. 자바코드의 비지니스 로직은 저장된 모든 멤버들을 List 형태로 가져 오는 것이다. 그리고 아래에서는 HTML 랜더링을 실행했다. 이 때, 위에서 말한 것처럼 JSTL을 이용해 Loop문으로 하나하나 출력하는 것이었다. 실행 결과는 위에서 확인할 수 있다.
JSP로 웹 어플리케이션 구현 결과
- JSP 파일 내에서 HTML과 비즈니스 로직이 다 돌아간다.
- JSP 파일 내에서는 HTML 디버그가 좀 더 쉬워졌다.
서블릿만으로 웹 어플리케이션을 구현했을 때, 2번 문제로 많은 고통을 받았다. 이걸 JSP로 구현하게 되면 2번 문제는 개선이 되는 것을 알 수 있었다. 즉, HTML 디버그가 좀 더 쉬워졌다.
JSP, 서블릿만으로 웹 어플리케이션 구현 결과 정리
JSP만으로 웹 어플리케이션을 구현하거나, 서블릿만으로 웹 어플리케이션을 구현해봤다. 이런 저런 불편함이 있었는데, 가장 큰 불편함은 하나의 JSP 혹은 서블릿에서 너무 많은 일을 한다는 것이다. JSP를 예로 들어보자. 우리가 짠 코드의 상단에는 주로 자바 비즈니스 로직이 들어갔고, 하단에는 주로 HTML 랜더링이 들어갔다. 하는 일이 완전히 분리된 것을 알 수 있다.
JSP가 하는 일은 완전히 분리가 가능한 것처럼 보이는데, JSP가 혼자서 하는 일은 너무 많다. 비즈니스 로직도 처리해야하고, View도 처리해야한다. 이렇게 되면 유지보수 관점에서 아주아주 어려워진다. 코드가 수천줄이 된다면, 간단한 버튼을 하나 바꾸는 것에도 쩔쩔 메야한다. 왜냐하면 이걸 살짝 바꾸는게 비즈니스 로직에 어떤 영향을 줄 수 있을지 모르기 때문이다. 그렇다면 어떻게 하면 좋을까?
JSP에서 봤을 때, 비즈니스 로직과 랜더링이 상단/하단으로 나뉘는 것을 볼 수 있었다. 즉, 하는 일을 나눌 수 있다. 비즈니스 로직, HTML 랜더링으로 일을 나눌 수 있다. 그리고 비즈니스 로직은 서블릿에서 좀 더 처리하게 편했고, HTML 랜더링은 JSP에서 하는 것이 편했다는 것을 떠올려보면 다음과 같이 코드를 나눠볼 수 있다!
- 서블릿 : 비즈니스 로직만 담당한다. → Controller
- JSP : View 랜더링만 담당한다. → View
위처럼 나누게 되면, 유지보수 관점에서 아주 쉬워질 수 있다. 비즈니스 로직을 건드리고 싶으면 서블릿만 보면 되고, View 랜더링만 건드리고 싶으면 JSP 파일만 건드리면 되기 때문이다. 이제 밑에서는 동일한 것들을 Controller와 View로 나눠본다.
MVC 패턴으로 리팩토링 하기(Controller / View로 해보기)
먼저 위에서 해왔던 것은 하나의 JSP, 서블릿에서 너무나 많은 일을 했다는 것이다. 이전 상태를 살펴보면 위처럼 일을 하고 있었던 것이다. 특히 JSP 코드에서 저것과 똑같이 동작한 것을 볼 수 있다. 그렇지만 이렇게 하면 유지보수 관점에서 매우매우 어렵다는 것을 알 수 있다. 그렇다면 어떻게 나누면 좋을까?
MVC 패턴
- Model : 데이터를 가지고 있음. 뷰가 필요한 모든 데이터를 가지고 있기 때문에 View는 비즈니스 로직, 데이터 접근 방법을 몰라도 됨.
- Controller : HTTP 요청을 받아서 파라미터 검증. 비즈니스 로직 실행. Model에 데이터 전달.
- View : 모델에 있는 데이터를 사용해서 화면을 랜더링함.
위의 문제점을 해결하기 위해 MVC 패턴이 나왔다. MVC 패턴의 요점은 '변경의 라이프 사이클'이 다른 녀석들을 구분했다는 것이다. UI를 변경하는 것과 비즈니스 로직을 변경하는 것은 각각 다르게 발생할 가능성이 매우 높고, 대부분 서로에게 영향을 주지 않는다. 따라서, 이렇게 변경의 라이프 사이클이 다른 녀석들을 다르게 관리해서 유지보수의 활용성을 높여주는 것이다.
먼저 이번에 구현하고자 하는 것은 왼쪽의 MVC 패턴이다. 왼쪽의 MVC 패턴을 보면 하던 일이 나뉘어진 것을 볼 수 있다.
- 클라이언트로 요청이 오면 Controller는 HTTP 파라미터를 검증하고, 비즈니스 로직을 실행하고 그 값을 Model에 담아준다. 그리고 제어권을 View에게 넘긴다.
- View는 제어권을 받은 후, 모델의 데이터를 참조해서 필요한 HTML을 랜더링한다. 그리고 그것을 클라이언트에게 응답한다.
그렇지만 실제로 자주 사용하는 패턴은 오른쪽에 있는 패턴이라고 한다. 컨트롤러 로직과 서비스 계층이 나뉘어진다. 이유는 유사하다. 컨트롤러가 비즈니스 로직 + 데이터 접근까지 하면 너무 많은 일을 하기 때문에 유지보수 관점에서 좋지 않다는 것이다. 오른쪽은 어떻게 동작하는 걸까?
- 클라이언트로 요청이 오면 Controller는 HTTP 파라미터를 검증한다. HTTP 파라미터를 서비스 계층에게 넘겨준다
- 서비스 계층은 HTTP 파라미터를 바탕으로 비즈니스 로직을 수행하고, 수행한 결과를 Controller에 전달한다.
- Controller는 받은 데이터를 Model에 전달하고, 제어권을 View로 넘긴다.
- View는 Model을 참조해서 랜더링을 한다. 그리고 응답한다.
MVC 패턴 실습 전 알아야 할 것.
- /WEB-INF
- 이 경로 안에 들어있는 파일들은 외부에서 직접 호출할 수 없다.
- dispatcher.forward()
- 서버 내부에서 호출이 일어난다. 이 때, HTTP 요청은 그대로 유지된다.
- redirect vs forward
- redirect는 클라이언트가 처음에 한 요청에 대해서 응답이 나간다. 그리고 그 응답에 새로운 URI가 정해지고, 클라이언트가 다시 한번 서버에 요청을 하는 것이다. forward는 클라이언트가 처음에 한 요청은 유지되면서 서버가 내부적으로 서버끼리 요청을 하는 것으로 볼 수 있다.
- redirect는 요청 경로가 바뀌는 것을 볼 수 있고 클라이언트가 인지할 수 있다. forward는 클라이언트가 전혀 인지하지 못함.
MVC 패턴으로 리팩토링 하기 실습
위에서 말한것처럼 MVC 패턴으로 리팩토링 하기 위해서 기본에 JSP가 하던 일을 비즈니스 로직과 View 랜더링으로 나눴다. 그리고 비즈니스 로직은 서블릿(Controller)을 통해서 처리하도록 했고, HTML 랜더링(View)은 JSP를 통해서 처리하도록 했다.
그리고 아직은 모델이 따로 없다. 모델이 없기 때문에 Request 객체를 이용하도록 한다. Request 객체는 내부적으로 Map 형태의 저장소를 가지고 있는데, 이 저장소를 setAttribute + getAttribute를 이용해서 Model처럼 사용한다.
MvcMemberFormServlet (HTML Form Controller)
@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);
}
}
HTML-FORM 요청을 받는 HTML을 비즈니스 로직에서는 딱히 할 일이 없다. 왜냐하면, 데이터를 처리하거나 그런 것이 없기 때문이다. 얘들이 해야하는 일은 HTML FORM을 랜더링해주는 VIEW로 제어권을 넘기는 것이다.
VIEW의 제어권을 넘기기 위해서 viewPath에 대한 dispatcher를 생성하고, forward 메서드를 통해 request, response 객체를 넘겨서 VIEW로 넘어가게 했다.
new-form.jsp (HTML Form View)
<%@ 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>
HTML FORM은 앞에서 계속 쓰던 것과 동일하다. HTML FORM을 랜더링해준다. 그리고 결과가 나오면 "save"라는 상대경로를 가지는 쪽으로 데이터를 POST해준다. 상대경로는 현재 경로 + save가 되는 것이다. 현재 흐름을 살펴보면 다음과 같다.
http://localhost:8080/servlet-mvc/members/new-form으로 들어왔다. save 상대경로를 적용하게 되면 http://localhost:8080/servlet-mvc/members/save로 넘어가게 된다.
MvcMemberSaveServlet(저장 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);
request.setAttribute("member", member);
// view 제어권 넘김
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
비즈니스 로직을 여기서 실행한다. 그리고 저장된 값을 모델(request)에 setAttribute로 저장한다. View로 제어권을 넘기는 것을 볼 수 있다.
save-result.jsp(저장 View)
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="himvc.servlet.domain.member.Member" %>
<%@ page import="himvc.servlet.domain.member.MemberRepository" %>
<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의 Map에는 현재 저장된 member가 저장되어있다. 여기서는 member의 각 필드를 동적으로 읽어와서, 정상 저장된 경우에 값을 보여주는 형태다.
MvcMemberListServlet(전체 회원 조회 - Controller)
@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);
}
}
전체 회원을 조회하는 비즈니스 로직을 구현했다. Colletion 형태로 불러온 값을 members에 저장해두고, 이 값을 request의 map 저장소에 저장했다. 그리고 view로 랜더링을 넘겼다.
mebers.jsp(전체 회원 조회 → View)
<%@ 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>
넘어온 request를 바탕으로 JSTL을 통해 다시 한번 값을 출력해준다.
구현 후 정리
MVC 패턴을 위와 같은 형태로 정리를 했다. 각 컨트롤러를 Servlet으로 구현하고, MVC를 적용한 셈이다. 실제 동작은 오른쪽 그림처럼 하게 되는데, 컨트롤러A는 회원 저장, 컨트롤러B는 회원 목록 조회 등처럼 이해를 할 수 있다. 그리고 각 서블릿등을 다음과 같이 정리할 수 있다.
- 회원등록폼 컨트롤러 → 로직이 전혀 없다. 패턴 유지를 위해 컨트롤러를 만들어둠. VIEW로 Forward.
- 회원저장 컨트롤러 → 서블릿 컨트롤러가 요청정보를 파싱, 멤버 객체를 만들고 Repository에 저장하는 비즈니스 로직 실행함. Request 객체(모델)에 값을 담고 VIEW로 Forward.
- 회원조회 컨트롤러 → 회원목록조회 컨트롤러가 요청정보 파싱. members 객체를 만들고 모델에 값을 담는다. VIEW로 Forward함.
그렇다면 정말로 이게 최선의 방법일까? 그렇지 않다.
@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
살펴보면 위의 코드가 계속 반복적으로 사용되는 것을 볼 수 있다. 즉, 각 컨트롤러가 동일하게 실행해야 하는 로직이 있다는 것이다. 이런 로직들은 컨트롤러가 1~2개면 상관이 없으나 수백개가 되면 문제가 될 수 있다. 너무나 많기 때문에 설정하는 과정에 누락이 일어날 수 있다는 것이다. 이런 단점 등을 정리해보면 다음과 같다
- FORWARD 코드 중복.
- View로 이동하는 코드가 항상 중복 호출되어야함.
- ViewPath 중복
- ViewPath의 Prefix, Suffix가 항상 공통된다. (/WEB-INF, .jsp)
- 많은 ViewPath 변경 시, 어느 세월에 다 바꾸지?
- 사용하지 않는 코드
- Response를 현재 거의 사용하지 않는 상황(JSP로 바로 뿌리는 중)
- 공통처리가 어렵다.
- 기능이 복잡해질수록 컨트롤러에서 공통으로 처리해야 할 부분이 많아질 것이다. 메서드로 뽑아도 되지만, 결과적으로 해당 메서드를 항상 호출해야한다. 그런데 실수로 호출하지 않으면 문제가 발생한다.
결론을 정리해보면, 공통으로 처리되는 부분이 많은데 이걸 어떻게 한번에 처리할 수 있게 바꾸면 좋겠다는 것이다. 지금까지는 각 Controller를 개별적으로 개발을 했었는데, 이 Controller들 중 공통으로 해야할 일을 하나로 빼고, 그 일만 처리해주는 수문장을 하나 만들면 좋겠다.
즉, 스프링 MVC에서 가장 중요한 FrontController를 하나 만들고, 이 FrontController가 다른 Controller들의 공통적인 부분을 처리하고 데이터 검증을 하게 만든다.
MVC 패턴 구현, Version1 (FrontController 도입)
Version1에서의 목표는 아래와 같다.
- FrontController를 도입해서, 1개의 서블릿만 생성한다. 그리고 FrontController에서 공통 업무를 처리한다.
- 기존 구조를 최대한 유지한다.
FrontControllerV1
@WebServlet(name = "frontControllerV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerV1 extends HttpServlet {
private Map<String, ControllerV1> controllerV1Map = new HashMap<>();
public FrontControllerV1() {
controllerV1Map.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
controllerV1Map.put("/front-controller/v1/members", new MemberListControllerV1());
controllerV1Map.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV1 controller = controllerV1Map.get(requestURI);
controller.process(request, response);
}
}
다음과 같이 FrontControllerV1을 만들었다. FrontController는 크게 두 가지 변경점이 있다.
- FrontController의 URI Pattenr 매칭은 /*로 된다.
- FrontController는 URI Pattern 매칭을 위한 Map 저장소가 있다.
/*로 URI 패턴을 매칭한 것은, 뒷쪽에 어떤 URI가 오더라도 무조건 FrontControllerV1로 오기 하게 위함입니다. FrontController에서 모든 Controller를 처리하겠다는 의미다. 각 URI에 맞게 Controller를 처리하기 위해서는 뭐가 필요할까? 맞다. URI와 컨트롤러가 매칭된 저장소가 필요하다.
FrontController는 내부적으로 URI Pattern과 Controller가 매칭된 저장소를 가진다. 그래서 일단 V4로 들어오고, 뒷쪽에 URI 패턴으로 저장소 내부를 검색한다. 저장소 내부를 검색했을 때, 원하던 컨트롤러를 찾아서 가지고 온다. 그리고 그 컨트롤러를 호출하면서 비즈니스 로직을 실행한다.
public class MemberFormControllerV1 implements ControllerV1 {
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
}
public class MemberListControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(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);
}
}
public class MemberSaveControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(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);
}
}
컨트롤러들의 구체를 볼 수 있다. 구체들을 보면 알 수 있는데, 구체들에서 바로 VIEW로 넘어가는 것을 볼 수 있다. 이 공통적인 로직은 추후에 한 군데로 모아서 개선할 점이 있다는 것을 의미한다.
MVC 패턴 구현, Version1 (FrontController 도입) 결과
- FrontController를 도입해서, 한 개의 서블릿만 등록해서 나머지 Controller를 도입할 수 있었다.
- FrontController의 URI 패턴에 /*를 사용함
- FrontController 내에 URI 패턴 매칭 저장소를 만듦.
- 기존 구조를 거의 바꾸지 않고, MVC 패턴을 구현했다.
위 변경점을 적용해서 MVC 패턴을 구현했다. 현재는 각 컨트롤러에서 View로 직접 접근하는 것을 볼 수 있다. 그런데 FrontController라는 것을 도입했기 때문에 각 컨트롤러의 실행 결과를 FrontController가 반환받고, View로 넘겨주면 더 좋은 형태가 될 것으로 예상된다. Version 2에서는 FrontController에게 결과를 반환해서, FrontController가 View를 실행하는 형태로 구조를 변경해본다.
MVC 패턴 구현, Version2 (FrontController가 View로 넘겨주는 구조)
Version2에서의 목표는 아래와 같다.
- 각 Controller의 결과물을 FrontController로 반환한다.
- FrontController는 그 결과물을 바탕으로 View에게 넘겨주고, View로 Forward 해준다.
FrontControllerV2
@WebServlet(name = "frontControllerV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerV2 extends HttpServlet {
private Map<String, ControllerV2> controllerV2Map = new HashMap<>();
public FrontControllerV2() {
controllerV2Map.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
controllerV2Map.put("/front-controller/v2/members", new MemberListControllerV2());
controllerV2Map.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV2 controller = controllerV2Map.get(requestURI);
MyView mv = controller.process(request, response);
mv.render(request,response);
}
}
앞서 이야기한 것처럼 FrontController는 각 컨트롤의 비즈니스 로직 결과물을 반환받고, 그 반환물을 직접 View로 넘겨주는 역할을 하기로 했다. 이 역할을 하기 위해서 두 가지 변경점이 생겼다.
- 컨트롤러 실행 결과에 대한 반환값은 MyView 타입을 받는다.
- MyView 타입의 변수 render()로 매개변수 request, response를 넘겨주어 View로 넘어간다.
결과물을 받아서, 그걸로 View를 넘겨주겠다는 것이 코드에 반영되었다. 그렇다면 여기서 MyView가 무엇인지에 대해서 살펴봐야한다. MyView는 어떤 필드를 가지고 있고, MyView의 rend() 메서드는 무슨 일을 하는 것일까?
MyView 도입
@Getter@Setter
public class MyView {
private String viewName;
public MyView(String viewName) {
this.viewName = viewName;
}
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String viewName = this.getViewName();
RequestDispatcher dispatcher = request.getRequestDispatcher(viewName);
dispatcher.forward(request, response);
}
myView는 기존에 disPatcher를 만들어서 forward하던 로직이 들어가있는 클래스다. 이 클래스에 viewName, 예전에 viewPath라고 하던 실제 경로값을 넣어주고, Request + Response 객체를 넣어서 응답을 하겠다는 것이다. 이렇게 하면 Controller 구체들의 코드는 어떻게 바뀔까?
Controller 구체 코드
public class MemberFormControllerV2 implements ControllerV2 {
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
return new MyView("/WEB-INF/views/new-form.jsp");
}
}
public class MemberListControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
List<Member> members = memberRepository.findAll();
request.setAttribute("members", members);
return new MyView("/WEB-INF/views/members.jsp");
}
}
public class MemberSaveControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(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);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
각 Controller의 구체 코드들은 위와 같다. 변경점을 살펴보면 다음과 같다.
- distpahcer를 만들고, Forward 해주는 코드가 사라짐.
- 컨트롤러의 실행 결과로 String 값(viewPath)을 반환함.
1번이라는 것을 실행하기 위해서 2번의 코드가 추가된 것으로 이해를 하면 될 것 같다. 어찌되었건 1번에서 반복 코드들이 최적화 된 것을 이해할 수 있다.
MVC 패턴 구현, Version2 (FrontController 도입) 결과
- FrontController가 Controller 수행 결과를 리턴 받는다.
- FrontController가 수행 결과를 바탕으로 View를 실행해준다.
- View 실행을 위해 MyView 클래스가 추가됨. MyView 클래스에서 render() 메서드로 view 실행함.
- 기존 Controller에서 공통 코드(dispatcher)가 사라짐
위 변경점 적용을 통해서 코드 유지보수 관점에서 좀 더 좋은 코드를 만들었다. 또한, MVC 패턴에 좀 더 근접한 패턴을 만들 수 있게 되었다. 그런데 이게 최선일까? 살펴보면 아니다. 왜냐하면 컨트롤러의 비즈니스 로직을 실행할 때 의미없는 Request, Response 값들이 넘어간다.
이 말이 무슨 말일까? 현재의 프로세스 비즈니스 로직은 HttpServlet이라는 기술에 종속적이라는 것을 의미한다. 그렇다면 왜 종속적일까?
- Model을 Request의 작은 지역 저장소에 저장한다.
- 비즈니스 로직에 필요한 데이터가 모두 request에 있다.
종속적인 것에서 벗어나기 위해서는 위의 두 가지만 해결해주면 될 것이다. Version 3에서는 이 두 가지를 해결해서 ServletRequest에서 벗어난 코드를 작성하고자 한다.
MVC 패턴 구현, Version3 (HttpServlet의 종속성에서 벗어난 컨트롤러)
Version3에서의 목표는 아래와 같다.
- Controller 실행 시, request가 아닌 필요한 데이터만 넘겨준다.
- Controller는 request가 아닌 ModelView 객체에 각 값을 저장한다. (Model)의 변경
FrontControllerV3
@WebServlet(name = "frontControllerV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerV3 extends HttpServlet {
private Map<String, ControllerV3> controllerV3Map = new HashMap<>();
public FrontControllerV3() {
controllerV3Map.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
controllerV3Map.put("/front-controller/v3/members", new MemberListControllerV3());
controllerV3Map.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV3 controller = controllerV3Map.get(requestURI);
if (controller == null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
MyView myView = viewResolver(mv.getViewName());
myView.render(request,response, mv.getModel());
}
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator().forEachRemaining(
paramName -> paramMap.put(paramName, request.getParameter(paramName))
);
return paramMap;
}
private MyView viewResolver(String viewName) {
MyView myView = new MyView("/WEB-INF/views/" + viewName + ".jsp");
return myView;
}
}
FrontController에서 이번에 구현하고 싶었던 것은 HttpServletRequest 객체에서 자유로워지는 것이었다. 그리고 앞에서 확인했던 바는 Request의 저장소를 Model로 쓰고 있었고, Request의 데이터를 Request 객체를 넘겨주면서 해결하기 때문에 문제가 있었다. 이것의 해결을 위해서 두 가지를 도입했다.
- Request의 Body 데이터 값을 가진 paramMap 변수를 도입하고 컨트롤러에게 넘겨준다. createParamMap 메서드를 통해 생성한다.
- ModelView라는 객체를 만들고, 그 객체를 Model로 사용한다.
- ModelView 객체는 논리적인 viewPath의 이름을 가진다. 물리적 viewPath로 수정을 위해 viewResolver 메서드를 이용한다.
- MyView에 request, response + Model을 던져서 Render한다.
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator().forEachRemaining(
paramName -> paramMap.put(paramName, request.getParameter(paramName))
);
return paramMap;
}
파라미터 맵을 만드는 메서드는 다음과 같다. request 객체에서 모든 파라메터를 불러오고, 그 파라메터를 HashMap에 저장한다. 그리고 HashMap을 반환한다.
private MyView viewResolver(String viewName) {
MyView myView = new MyView("/WEB-INF/views/" + viewName + ".jsp");
return myView;
}
ViewResolver 메서드는 다음과 같다. 기존의 경로는 prefix가 /WEB-INF/views/로 같았고, suffix는 모두 .jsp로 같았다. 이것을 활용해서 논리적인 주소명을 물리적은 주소로 바꾸는 일을 한다. 그리고 그 결과물을 myView의 viewName에 저장하고 myView를 반환해준다.
'' MyView Class
public void render(HttpServletRequest request, HttpServletResponse response, Map<String, Object> model) throws ServletException, IOException {
setModelToAttributes(request, model);
String viewName = this.getViewName();
RequestDispatcher dispatcher = request.getRequestDispatcher(viewName);
dispatcher.forward(request, response);
}
private void setModelToAttributes(HttpServletRequest request, Map<String, Object> model) {
model.forEach((key, value) -> request.setAttribute(key, value));
}
MyView 클래스는 render()에 대한 추가 메서드가 만들어져야 한다. 현재 우리의 jsp파일은 request의 임시 모델(?)에 저장된 값을 바탕으로 동적으로 표현해준다. 그렇지만 우리는 현재 ModelView()라는 객체의 Map 타입의 Model 저장소에 필요한 데이터가 저장되어있다. 따라서 Model과 request의 임시 모델 저장소가 동기화 되는 과정이 필요하다. 이 과정을 setModelToAttributes 메서드로 풀어낼 수 있고, 나머지는 동일하게 진행이 된다.
Controller 구체 코드
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) throws ServletException, IOException {
ModelView mv = new ModelView("new-form");
return mv;
}
}
public class MemberListControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) throws ServletException, IOException {
List<Member> members = memberRepository.findAll();
ModelView mv = new ModelView("members");
mv.getModel().put("members", members);
return mv;
}
}
public class MemberSaveControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) throws ServletException, IOException {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
위는 각 Controller의 구체코드다. 가장 큰 변경점을 살펴보자.
- 매개변수로 paramMap만 받는다 → HttpServlet에서 독립적으로 바뀜.
- 데이터 저장을 request가 아닌, ModelView의 Model 저장소에 한다 → HttpServlet에서 독립적으로 바뀜.
- 리턴을 기존에는 절대주소로 해주었으나, 지금부터는 ModelView로 해준다.
위의 코드가 반영이 되었다. 즉, 목표한 것처럼 HttpServlet에서 독립적으로 바뀌었다.
MVC 패턴 구현, Version3 (HttpServlet으로 부터 독립) 결과
- 매개변수로 paramMap만 받는다 → HttpServlet에서 독립적으로 바뀜.
- 데이터 저장을 request가 아닌, ModelView의 Model 저장소에 한다 → HttpServlet에서 독립적으로 바뀜.
- 리턴을 기존에는 절대주소로 해주었으나, 지금부터는 ModelView로 해준다.
위 코드는 좀 더 개선될 여지가 있을까? 대답은 '그렇다'이다. 왜냐하면 각 컨트롤러들이 ModelView 객체를 직접 생성해서 쓰고 있기 때문이다. 이런 공통 코드들을 하나로 묶으면 좀 더 관리가 수월하지 않을까? 맞다. 그래서 다음에는 FrontController에서 이렇게 Model을 직접 만들어서 넘기는 형식으로 리팩토링을 하고자 한다.
MVC 패턴 구현, Version4 (유지보수 개선, 모델을 직접 매개변수로 넣어준다.)
Version4에서의 목표는 아래와 같다.
- FrontController에서 직접 모델을 생성한다.
- FrontController에서 생성한 모델을 각 Controller에 넘겨주고, Controller에서 그것을 사용한다.
FrontController 코드
@WebServlet(name = "frontControllerV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerV4 extends HttpServlet {
private Map<String, ControllerV4> controllerV4Map = new HashMap<>();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV4 controller = controllerV4Map.get(requestURI);
if (controller == null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
Map<String, String> paramMap = createParamMap(request);
Map<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
MyView myView = viewResolver(viewName);
myView.render(request,response, model);
}
FrontControllerV4에서 생긴 변경점은 세 가지다.
- 객체를 담을 수 있는 model을 직접 만들어준다.
- Controller의 Process를 수행할 때, model을 넘겨준다.
- 수행 결과로 viewName을 받는다. 이걸로 myView를 만들어준다.
위에서 볼 수 있는 것처럼 코드를 리팩토링하면 나머지 컨트롤러에서는 어떻게 코드가 바뀌었을까?
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) throws ServletException, IOException {
return "new-form";
}
}
public class MemberListControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) throws ServletException, IOException {
List<Member> members = memberRepository.findAll();
model.put("members", members);
return "members";
}
}
public class MemberSaveControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) throws ServletException, IOException {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
model.put("member", member);
return "save-result";
}
}
다음과 같이 코드가 바뀐다. 각 컨트롤러는 new ModelView()를 통해서 ModelView 객체를 하나 생성하고, 거기의 Model 저장소를 활용했었다. 그렇지만 이제는 FrontController가 준 Model 저장소를 직접 사용하고, return은 상대적인 주소만 return하는 것을 볼 수 있다.
MVC 패턴 구현, Version4 (매개변수로 Model 넘겨주기) 결과
- FrontController가 Model을 직접 생성해서 넘겨준다.
- Controller는 FrtonController가 준 Model만 쓴다.
위 변경점 적용을 통해 유지보수 관점에서 좋은 코드를 만들었다. 기존에는 각 Controller가 직접 ModelView를 생성하는 노가다를 했었는데, 지금부터는 FrontController가 하나 Model을 만들어서 뿌려주는 식으로 동작한다. 그래서 추후에 Model이 필요없어지게 되면, FrontController에서 Model 생성 코드를 한 줄 지우기만 하면 된다.
여기에서 좀 더 나아갈 수 있는 방법은 어떤 것이 있을까? 예를 들어서 이럴 수 있겠다. 어떤 개발자는 V3 버전으로 개발하고 싶어 하고, 어떤 개발자는 V4 버전으로 개발하고 싶을 수 있다. 그런데 이걸 쉽게 해결할 수 있을까? 그렇지는 않다. 왜 그럴까?
먼저 V3, V4 버전은 Input으로 들어가는 매개변수가 다르고, 돌아오는 Output 매개변수도 다르다. 따라서, 일률적으로 사용할 수 없는 것처럼 보인다. 그렇지만, MVC 패턴에 Adapter라는 개념을 도입하면서 이걸 해결할 수 있다. Adapter는 전기 콘센트에서 쓰는 것과 비슷하다고 보면 된다.
예를 들어 110V를 쓰고 있는데, 220V 전기 공급이 필요하다면 우리는 110V - 220V 어댑터를 사용해서 전원을 공급한다. 이것과 마찬가지다. 특정 컨트롤러를 사용할 때, 필요한 어댑터를 찾아서 사용하면 우리가 원하는 목적을 잘 수행할 수 있다.
MVC 패턴 구현, Version5 (다양한 Version의 컨트롤러 사용, Adapter 도입)
Version5에서의 목표는 아래와 같다. 먼저 용어를 정리하면 Controller는 Handler로 변경이 되었다.
- Handler에 맞는 Adapter가 있는지 확인한다.
- Handler에 맞는 Adapater가 있으면 비즈니스 로직을 handle()을 통해서 실행한다.
- Handler Adapater는 handle() 메서드에서 각 컨트롤러의 process()를 실행해준다.
FrontController의 변화
public FrontControllerV5() {
initHandlerMapping();
initHandlerAdapater();
}
private void initHandlerMapping() {
handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
}
private void initHandlerAdapater() {
handlerAdapterList.add(new ControllerV3HandlerAdapter());
handlerAdapterList.add(new ControllerV4HandlerAdapter());
}
FrontController는 기존에는 URI 패턴과 매칭되는 컨트롤러를 저장하는 저장소만 가지고 있었다. 어댑터 패턴이 들어오게 되면서 저장소도 변경이 필요하다. 저장소는 크게 2개가 된다.
- URI 패턴에 매칭되는 컨트롤러를 저장하는 저장소
- 각 컨트롤러에 대한 어댑터 저장소
저장소가 2개가 생겼으니, FrontController 서블릿이 생성될 때 초기화도 두 가지 저장소에서 다 되어야한다. 여기서 handlerMappingMap은 기존의 ControllerMap과 동일한 역할을 한다. handlerAdapaterList는 Adapeter만 보관하는 곳이다.
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
Object handler = handlerMappingMap.get(requestURI);
if (handler == null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter myHandlerAdapter = getMyHandlerAdapter(handler);
ModelView mv = myHandlerAdapter.handle(request, response, handler);
MyView myView = viewResolver(mv.getViewName());
myView.render(request,response, mv.getModel());
}
초기화가 된 후에는 Service 메서드가 실행된다. 여기서 HandlermappingMap에서 필요한 컨트롤러(핸들러)를 먼저 찾아오는 것을 볼 수 있다. 그리고 그 다음으로는 handlerAdapater를 찾아오는 getMyHandlerAdapater 메서드가 실행되는 것을 볼 수 있다. 이후네는 어댑터에 핸들러를 넘겨주면서 비즈니스 로직을 실행시킨다. 그리고, 받은 ModelView 객체를 바탕으로 Forward를 해준다.
private MyHandlerAdapter getMyHandlerAdapter(Object handler) {
for (MyHandlerAdapter myHandlerAdapter : handlerAdapterList) {
if(myHandlerAdapter.support(handler)){
return myHandlerAdapter;
}
}
throw new IllegalArgumentException("adapater를 찾을 수 없습니다. handler : " + handler);
}
핸들러 어뎁터 정보를 찾아오는 getMyHandlerAdapater 메서드의 상세 코드는 다음과 같다. 핸들러 어댑터가 저장된 LIst를 한바퀴 돌리면서, 현재 내 핸들러와 맞는 어댑터가 있는지를 살펴본다. 그리고 핸들러와 맞는 어댑터가 있다면 반환을 해주고, 그렇지 않다면 에러 메시지를 알려준다.
MyHandlerAdapeter
public interface MyHandlerAdapter {
boolean support(Object handler);
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
이번 버전에서는 myHandlerAdapater라는 인터페이스가 등장한다. 메서드는 두 가지가 있다.
- support() : 이 어댑터가 매개변수 핸들러를 지원하는지 확인
- handle() : 비즈니스 로직 수행
얘를 쓰기 위해서는 당연한 것이지만 구현체가 필요하다. 이 구현체를 각각 표현할 때, Override를 이용해 다형성을 사용할 수 있고, 이를 통해서 어댑터를 갈아끼는 것처럼 구현을 할 수 있다.
myHandlerAdapater의 구현체(ControllerV3 인터페이스용 어댑터)
@Override
public boolean support(Object handler) {
return handler instanceof ControllerV3;
}
먼저 V3와 호환하기 위해서 ControllerV3 타입과 호환되는 MyHandlerAdapater를 구현했다. support는 핸들러와 어댑터가 같은 타입인지를 보는 메서드라고 앞서 이야기했다. 따라서, 주어진 handler를 instanceof 메서드를 이용해 ControllV3 Interface와 같은 타입인지를 확인한다.
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV3 controller = (ControllerV3) handler;
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
return mv;
}
위는 비즈니스 로직을 실행한다. 먼저 handler는 최상위 클래스인 Object 객체이다. 따라서 잘 사용하기 위해서는 다운 캐스팅이 필요하다. 다운 캐스팅을 한 후에는 ControllerV3에서 process가 필요한 파라메터를 만들어주고, Controller V3에서 사용하던 것처럼 Process를 진행해주면 된다.
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV4 controller = (ControllerV4) handler;
Map<String, String> paramMap = createParamMap(request);
Map<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
}
위는 ControllerV4를 위한 어뎁터 구현체다. ControllerV4에서는 Process를 할 때 Model과 paramMap을 넘겨주었다. 필요한 변수를 생성해서 ControllerV4의 process 메서드를 수행하고 viewName을 받았다. 그런데 문제는 여기서 발생한다. viewName을 돌려주기 위해서 handle 메서드를 봤더니 타입은 ModelView다. 따라서 우리는 ModelView 형태로 바꾸어줘야한다.
그래서 ModelView 객체를 하나 만들고 Setter를 이용해서 각 객체들의 참조를 이어준다. 그리고 ModelView를 넘겨준다. 여기서 어뎁터라는 의미가 나온다. 사실 Return 타입만 보면, ControllerV4는 여기에 맞지 않다. 그런데 어댑터에서 반환 타입을 맞추어 주기 위해 객체를 생성해서 넘겨주었다. 즉, 110V → 220V로 바꾼 것처럼 어댑터가 역할을 한 것이다.
MVC 패턴 구현, Version5 (어댑터 도입) 결과
- 어댑터를 도입했다.
- 어댑터는 여러 타입의 컨트롤러를 자연스럽게 쓸 수 있도록 도와준다.
- 맞는 어댑터가 있으면 특정 컨트롤러의 비즈니스 로직을 수행할 수 있다.
- FrontController가 원하는 반환 타입이 맞지 않는 경우, 어댑터에서 반환 타입을 맞춰주는 역할을 한다.
기존의 MVC 패턴을 살펴보면, FrontController들은 특정 타입의 컨트롤러만 사용할 수 있었다. 예를 들어서 V3는 ControllerV3 인터페이스의 구현체들만 사용할 수 있었고, V4는 ControllerV4 인터페이스의 구현체들만 사용할 수 있었다. 이런 이유는 각 인터페이스의 매개변수도 달랐고, Return 타입도 달랐기 때문이다.
그렇지만 이번에는 어댑터라는 개념을 도입하면서 이것을 해결했다. 어댑터는 특정 인터페이스의 컨트롤러와 호환되는 어댑터를 각각 만들어두고, 그 어댑터를 이용해서 FrontController가 원하는 데이터 타입을 동일하게 맞춰준다. 즉, 하나의 중간 버퍼 계층이 생기면서 필요한 형태의 일을 해준다는 것이다.
우리는 이런 어댑터를 이용해서, 핸들러(Controller)가 어떤 타입이든 상관없이 메서드를 실행해서 결과를 받고, 그 결과를 View로 랜더링 할 수 있게 되었다. 여기까지 구현한 다음에 @Annotation 기반의 Controller를 구현하면 그것이 스프링 MVC라고 한다.
'Spring > Spring MVC' 카테고리의 다른 글
Spring MVC : 가장 기초적인 부분들 정리 (0) | 2021.12.20 |
---|---|
Spring MVC : 간단한 웹 페이지 구현 (0) | 2021.12.19 |
Spring MVC : HttpServletRequest에 대한 정리 (0) | 2021.12.10 |
Spring MVC : 스프링 환경에서 서블릿 사용해보기 (0) | 2021.12.09 |
Spring MVC : 스프링 스타터로 프로젝트 생성하기 (0) | 2021.12.09 |