• [인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(3)

    2022. 5. 31.

    by. 웰시코더

    반응형

    -회원관리 웹 애플리케이션을 간단하게 만들어본다.
    -처음에 서블릿 방식만 사용하여 만들고 JSP 방식으로도 만들어본다.
    -마지막으로 MVC방식으로 만들어 서블릿방식, JSP방식의 단점을 보완해본다.
    -모든 소스는 깃허브에서 관리한다.(https://github.com/coderahn/Spring-Lecture4)


     

    3.서블릿, JSP, MVC 패턴


    1.회원 관리 웹 애플리케이션 요구사항

    이전 시간에는 서블릿에서 요청 데이터를 가져와서 처리하는 방법, 응답 데이터를 보낼 때 처리하는 방법 등을 살펴보았다. 이번 시간에는 본격적으로 간단한 회원관리 웹 애플리케이션을 만들어본다.
    처음에는 서블릿을 통해서만 개발하면서 자바 코드 안에 HTML 작성을 해본다.
    두번쨰로 서블릿을 통한 개발의 단점을 느끼고, JSP를 통한 개발을 해본다. JSP에 자바코드를 섞어 넣어보면서 사용해본다.
    마지막으로 각각의 단점을 극복하기 위해 MVC패턴을 사용하여 개발해본다.

    우선 간단한 요구사항을 정리하자면,

    • username, age를 입력할 수 있음
    • 저장을 누르면 회원관리 DB에 데이터가 저장됨(여기서는 HashMap을 통한 간단한 메모리DB 사용)
    • 저장된 회원정보를 목록형태로도 볼 수 있음


    우선 회원 username, age를 저장하고 가져올 수 있는 도메인 모델을 다음과 같이 만든다.

    [Member.java]

    @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;
        }
    }


    그리고 회원 저장(save), 아이디로 회원찾기(findById), 회원전체 찾기(findAll), DB클리어(clearStore) 메소드를 정의하는 MemberRepository 클래스를 다음과 같이 만든다. 저장할 장소는 static HashMap을 선언하여 메모리를 일시적으로 DB로 사용한다.

    [MemberRepository.java]

    public class MemberRepository {
        //static으로 MemberRespository 인스턴스 생성이 계속 되어도 1개만 생성
        private static Map<Long, Member> store = new HashMap<>();
        private static long sequence = 0L; //ID 시퀀스
    
        private static final MemberRepository instance = new MemberRepository();
    
        //싱글톤으로 만들기 때문에 위에 store의 static은 빼도 상관없으나 그냥 static 처리
        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();
        }
    }



    잘 보면 MemberRepository는 싱글톤으로 생성할 수 있도록 처리가 되어 있다. 싱글톤이기 때문에 내부의 store, sequence 등 변수에 static을 굳이 붙이지 않아도 되지만 여기서는 의미상 붙여 사용한다.

    메소드의 로직들에 대해 간단하게 살펴보면,

    • save : Member의 setId를 통해 고유값을 증가시켜 셋팅하고 그 id를 store Map에 Key값으로, Member 객체를 value로 저장한 후 저장한 member 반환
    • findById : id를 파라미터로 넘겨 store에서 꺼내옴
    • findAll : store의 values()를 통해 ArrayList형태로 모두 꺼내옴
    • clearStore : map을 클리어 처리


    다음과 같이 테스트 코드를 작성해서 테스트해본다.

    [MemberRepositoryTest.java]

    //junit5부터 public이 없어도 된다.
    class MemberRepositoryTest {
        MemberRepository memberRepository = MemberRepository.getInstance();
    
        //테스트(@Test) 끝날 때 마다 초기화
        //테스트는 순서 보장이 안 되기 때문에 clear 필수
        @AfterEach
        void afterEach() {
            memberRepository.clearStore();
        }
    
        @Test
        void save() {
            //given
            Member member = new Member("hello", 20);
    
            //when
            Member saveMember = memberRepository.save(member);
    
            //then
            Member findMember = memberRepository.findById(saveMember.getId());
            assertThat(findMember).isEqualTo(saveMember);
        }
    
        @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
            //static import : alt + enter
            assertThat(result.size()).isEqualTo(2);
            assertThat(result).contains(member1, member2);
        }
    }



    2)서블릿으로 회원 관리 웹 애플리케이션 만들기

    순수하게 서블릿으로만 HTML form까지 작성할 수 있다. Servlet 자바 파일에 html 코드를 동적으로 넣으면 되는데 상상만 해도 코드가 지저분할 것 같다. 우선 개발해보자.

    다음과 같이 회원등록용 HTML을 제공할 수 있는 서블릿을 만든다.

    [MemberFormServlet.java]

    //회원등록용 HTML 서블릿
    @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");
        }
    }


    localhost로 해당 URL을 실행하면 다음과 같은 화면이 뜬다. 서블릿의 자바코드로 만든 HTML이다.


    전송을 클릭하여 DB에 저장 후 결과를 보여줄 서블릿을 다음과 같이 만들자.

    [MemberSaveServlet.java]

    @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);
    
            //save로직 수행 후 결과 HTML출력
            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>");
        }
    }


    저장을 눌러 위의 서블릿으로 호출하면 다음과 같은 결과 화면을 볼 수 있다.

    저장 후 결과화면


    마지막으로 목록을 보여줄 서블릿을 만들자.

    [MemberListServlet.java]

    @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");
    
            //멤버저장리스트 HTML출력
            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>");
        }
    }


    localhost:8080/servlet/members로 접속하면 다음과 같은 저장 리스트를 볼 수 있다.(미리 2개의 데이터를 저장했다)

    리스트 출력

    위의 서블릿 코드들을 보면 알겠지만 지저분하게 HTML 코드들이 들어가 있다. 이를 해결하기 위해 JSP를 사용해보자.

    3)JSP로 회원 관리 웹 애플리케이션 만들기

    JSP 사용시 라이브러리 추가를 해야한다.

    gradle 사용시 build.gradle에 다음과 같이 작성한다.

    repositories {
    	mavenCentral()
    }
    
    dependencies {
    	implementation 'org.springframework.boot:spring-boot-starter-web'
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    
    	//JSP 추가 시작
    	implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
    	implementation 'javax.servlet:jstl'
    	//JSP 추가 끝
    
    	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }


    회원 등록을 위한 폼을 JSP로 생성한다.

    [new-form.jsp]

    <%@ page contentType="text/html;charset=UTF-8" language="java" %> <!-- JSP 파일임을 명시 -->
    <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>


    위의 코드를 보면 첫 줄 제외하고는 HTML과 동일한 모습을 하고 있으며, 서버 내부에서 서블릿으로 변환된다.

    회원 저장 후, 결과를 보여줄 JSP는 다음과 같다.

    [save.jsp]

    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@ page import="hello.servlet.domain.member.Member" %>  <!-- java의 import 역할 -->
    <%@ page import="hello.servlet.domain.member.MemberRepository" %> <!-- java의 import 역할 -->
    <%
      //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>
    </body>
    </html>


    JSP에서는 동적 표현을 위해(JAVA 코드를 사용하기 위해) 스크립트를 사용하는데 스크립트는 3가지로 구성되어 있다.

    • 선언부 : <%! %>로 쓴다. 전역변수, 메서드 선언을 한다.
    • 스크립틀릿 : <% %>로 쓴다. 자바 코드를 입력한다.
    • 표현식 : <%= %>로 쓴다. 자바 코드를 출력한다.


    회원 목록을 보여줄 JSP도 만들어준다.

    [members.jsp]

    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@ page import="hello.servlet.domain.member.Member" %>
    <%@ page import="java.util.List" %>
    <%@ page import="hello.servlet.domain.member.MemberRepository" %>
    <%
      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>


    JSP에서는 스크립틀릿을 통해 반복문을 위와 같이 돌릴 수 있다. 이 JSP에 진입하면 회원 리포지토리를 통해 먼저 회원 리스트를 조회(findAll())하고 결과 List를 for(Member member: members) {...} 로 처리한다.

    서블릿과 JSP를 각각 한쪽만 사용하면 장단점이 명확하다.
    서블릿만 사용하면 자바 코드에 View 화면을 만드는 HTML코드가 섞여 코드가 지저분해진다.
    JSP를 사용하면 HTML 작업을 깔끔하게 처리할 수 있으나 중간중간 동적 변경 코드가 섞여 JSP가 너무 많은 역할을 한 번에 한다. 비지니스 로직과 데이터 접근 리포지토리 등 많은 코드가 섞여있는 것을 확인할 수 있다.

    이런 문제를 해결하기 위해 MVC 패턴이 등장한다. 비지니스 로직을 서블릿 같은 곳에서 몰아서 처리하고, JSP는 HTML로 화면을 그리는 일에 집중하도록 만든다. 다음에는 이를 적용해보려고 한다.

    4)MVC패턴 - 개요

    서블릿만 사용하거나 JSP만 사용하면 가벼운 변경 요구사항에도 코드 간 영향도가 높을 것이다. 예를 들어 버튼 하나 바꾸는 데에 비지니스 로직까지 수정해야하는 경우가 있을 수 있다.

    이런 문제 해결을 위해 서블릿은 비지니스 로직, JSP는 뷰랜더링 기능만 제공하도록 처리하는 방법을 MVC패턴이라고 한다. Model-View-Controller의 약자이다. 여기서 JSP가 View, 서블릿이 Controller 역할을 한다.
    Model의 기능은 request.setAttribute(), request.getAttribute() 등과 관련이 있다.

    5)MVC패턴 - 적용

    회원가입을 위한 등록 폼(JSP)으로 이동하도록 다음과 같이 만든다.

    [MvcMemberFormServlet.java]

    /**
     * MVC패턴
     * 컨트롤러
     */
    @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);
            //forward() : 다른 서블릿이나 JSP로 이동 가능. 서버 내부에서 다시 호출이 발생
            dispatcher.forward(request, response);
        }
    }


    dispatcher.forward()를 통해 다른 서블릿, JSP로 이동한다. 서버 내부에서 호출이 다시 발생한다.

    또한 viewPath를 보면 경로가 "/WEB-INF/..."로 되어 있다. /WEB-INF라는 경로 안에 JSP가 있으면 외부에서 직접 JSP를 호출할 수 없다. 컨트롤러를 통해 우회하여 접근할 수만 있다.(보안 목적 등)

    마지막으로 forward는 redirect와 비교가 자주 되는데, 리다이렉트의 경우 실제 클라이언트(브라우저)로 갔다가(302) 다시 redirect경로(location)로 요청을 보낸다. 즉 2번 요청, 응답이 오고간다. 반면, 포워드는 서버 내부에서 일어나는 호출로 클라이언트가 인지하지 못한다.

    회원을 등록하는 폼은 다음과 같이 만든다.

    [new-form.jsp]

    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <!-- save는 상대경로: 현재URL 계층경로 + /save -> (servlet-mvc/members/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>


    form의 action을 보면 상대경로로 되어 있다. 이 경우 현재 URL 계층경로 + save가 호출된다.

    • 현재 계층 경로 : /servlet-mvc/members/
    • 결과 : /servlet-mvc/members/save


    실행하면 다음과 같이 나온다.


    다음으로는 '전송'클릭시 회원 저장을 하는 컨트롤러를 다음과 같이 만든다.

    [MvcMemberSaveServlet.java]

    @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>
      <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>


    뷰를 보면 model에 request.setAttribute()로 담은 값을 request.getAttribute()로 꺼내야 하지만 이 코드가 보이지 않는 것을 알 수 있다. request.getAttribute() 대신 ${}문법을 사용하면 편리하게 모델값을 가져올 수 있다.

    회원목록 조회는 다음과 같다.

    [MvcMemberListServlet.java]

    @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);
        }
    }


    목록을 보여줄 member.jsp를 만든다.

    [members.jsp]

    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    <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>
        <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>


    List를 편하게 출력하기 위해 c태그를 사용하였다.

    5,6)MVC패턴 - 적용 및 한계

    MVC패턴을 사용해도 문제점 몇 가지가 보인다.

    • 포워드 중복 : VIew 이동 코드의 중복
    • ViewPath에 경로 중복 : /WEB-INF/views/가 중복되며 .jsp도 중복된다.
    • 사용하지 않는 코드 : response를 안 쓰는데도 forward에 보낸다.

    이런 문제 해결을 위해 컨트롤러 호출 전에 공통 기능을 처리해야한다. 선처리를 가능하게 해주는 프론트 컨트롤러 패턴을 도입하면 문제 해결이 가능하다.

    다음에는 MVC프레임워크를 만들어 프론트 컨트롤러 패턴을 적용해보려고 한다.

    반응형

    댓글