웹 개발/Spring

[SpringMVC] 스프링 MVC의 구조를 이해해보자 (1/3)

어썸오184 2021. 7. 9. 00:33
728x90
반응형

애너테이션 기반의 스프링 MVC는 정말 편리하다. 얼마나 편리하나면 그 구조를 모르고 사용할때는 마치 마법처럼 느껴질 정도이다…

나는 그냥 문자열만 반환했는데 어떻게 뷰를 찾는건지, 나는 그냥 객체를 반환했는데 어떻게 Json으로 변환해서 HTTP 메시지 바디에 값을 입력하는지…  또 어떻게 컨트롤러의 파라미터에 맞춰서 필요한 컨트롤러를 동작시키는지… 이 과정이 어떻게 일어나는지 정확히 알려면 RequestMappingHandlerAdapter와 HttpMessageConverter, ArgumentResolver의 동작 방식과 구조를 알아야한다.

김영한님의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의를 들으면서 스프링이 어떤 구조로 이런 다양한 변화에 맞게 컨트롤러를 호출하고 뷰를 반환하는지 알 수 있었는데, 핸들러 어댑터를 적용하는 부분이 조금 복잡해서 확실하게 와닿지 않았다. 핸들러 어댑터가 핸들러를 매핑해주는 과정이 조금 헷갈리다보니까 뒤에 메시지 컨버터가 동작하는 과정도 뭔가 확 와닿지 않았다. 그래서 포스팅으로 정리하면서 확실히 익혀보고자 한다.

우선 이번 포스팅에서는 회원 관리 서비스에 서블릿을 컨트롤러로 JSP를 뷰로 하는 MVC 패턴을 적용해보고, 아주 간단한 프론트 컨트롤러 패턴을 적용해보는 것까지 해보려고한다.

MVC 패턴으로 회원 관리 서비스 만들기

서블릿을 Controller로 JSP를 View로 해서 MVC 패턴을 이용해 만들어보자. 대략 이런식으로 돌아간다.

도메인 (Member, MemberRepository)

회원은 간단하게 id, username, age 필드만 구성한다. JSP를 사용할 것이기 때문에 롬복을 이용해 프로퍼티를 추가한다.

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

그리고 회원 목록을 저장할 레포지토리를 만든다. 이번 예제에서는 회원 목록만 조회할 것이기 때문에 역시 필요한 퍼블릭 인터페이스만 최소한으로 구현한다.

public class MemberRepository {

    private static final MemberRepository instance = new MemberRepository();
    private static final Map<Long, Member> store = new HashMap<>();
    private static Long sequence = 0L;

    private MemberRepository(){}

    public static MemberRepository getInstance() {
        return instance;
    }

    public Member save(Member member) {
        member.setId(sequence++);
        store.put(member.getId(), member);
        return member;
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
}

Controller와 View

먼저 ‘members/form’으로 요청이 오면 사용자 등록 폼을 보여주는 MemberFormController와 form.jsp를 작성한다.

해당 컨트롤러는 요청이 오면 단순히 form.jsp를 포워드한다.

MemberFormController.java

@WebServlet(name = "memberFormController", urlPatterns = "/members/form")
public class MemberFormController extends HttpServlet {

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

        String viewPath = "/WEB-INF/views/form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

form.jsp

<body>
<form action="save" method="post">
    username: <input type="text" name="username"/> age: <input type="text" name="age"/>
    <button type="submit">전송</button>
</form>
</body>

 

입력화면

username과 age를 입력하고 제출하면 ‘/members/save’ 가 호출된다.

위 URL로 요청이 오면 입력된 정보를 바탕으로 Member 객체를 생성하고 MemberRepository에 저장한 후 결과 화면을 호출하는 컨트롤러 MemberSaveController를 만들어보자.

@WebServlet(name = "memberSaveController", urlPatterns = "/members/save")
public class MemberSaveController extends HttpServlet {

    private final 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);

        // 저장된 멤버 정보를 보여주는 화면
        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

이를 그림으로 나타내면 다음과 같다.

save-result.jsp는 다음과 같이 모델에서 member 정보를 꺼내 화면을 그려낸다.

save-result.jsp

<body>
성공
<ul>
    <li>id=${member.id}</li>
    <li>username=${member.username}</li>
    <li>age=${member.age}</li>
</ul>
</body>

form.jsp

 

save-result.jsp

 

다음은 회원 목록을 모델에 담아서 members.jsp를 호출하는 MemberListController를 작성한다.

@WebServlet(name = "memberListController", urlPatterns = "/members")
public class MemberListController extends HttpServlet {

    private final 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);
    }
}

members.jsp는 전달받은 members 객체에서 회원 정보를 꺼내 화면을 그린다. (taglib 이용)

members.jsp

<body>

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

 

members.jsp

MVC 패턴의 한계

MVC 패턴을 적용하면 화면을 그리는 기능과 그 외적인 기능을 구분할 수 있어 코드가 깔끔해지고 각각의 역할이 명확해진다. 하지만 컨트롤러에는 여전히 아쉬운 부분들이 보인다.

viewPath 중복

MemberFormController.java

String viewPath = "/WEB-INF/views/form.jsp";

MemberListController.java

String viewPath = "/WEB-INF/views/members.jsp";

MemberSaveController.java

String viewPath = "/WEB-INF/views/save-result.jsp";

viewPath를 보면 앞에 “/WEB-INF/views” 그리고 끝에 “.jsp”가 매번 중복된다.

만약 회원과 관련된 뷰 템플릿들의 위치가 변경되거나, 뷰 템플릿이 JSP에서 다른 것으로 변경된다면? 모든 컨트롤러의 viewPath 부분을 수정해야한다. 지금은 컨트롤러가 3개밖에 없지만 프로젝트가 커져서 컨트롤러가 수십, 수백개가 된다면 큰 문제가 될 것이다.

포워드 중복

모든 컨트롤러에서 뷰로 이동하는 코드가 중복되어 있다.

  RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
  dispatcher.forward(request, response);

필요없는 매개변수

현재 컨트롤러들은 모두 HttpServlet에 정의된 service() 메서드를 호출한다. 때문에 항상 HttpServletRequest와 HttpServletResponse를 인자로 받고 있다. 하지만 이것이 컨트롤러가 수행할 역할에 반드시 필요할까? 특히 모든 컨트롤러에서 HttpServletResponse는 단순히 forward()를 호출하기 위한 용도로밖에 쓰이지 않는다. 이러한 문제를 어떻게 개선할 수 있을까?

프론트 컨트롤러 패턴

현재 서비스의 구조는 다음과 같다.

 

여러 컨트롤러의 공통 기능을 처리하는 프론트 컨트롤러 하나를 두고 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아 호출하는 프론트 컨트롤러 패턴을 적용해서 중복 문제를 해결할 수 있다.

프론트 컨트롤러는 모든 컨트롤러의 공통 기능을 처리한다. 클라이언트의 요청도 모두 이 프론트 컨트롤러가 받는다. 즉 프론트 컨트롤러를 두면 핵심적인 비즈니스 로직을 처리하는 컨트롤러는 서블릿에 종속될 필요가 없다.

이제 우리 서비스에 프론트 컨트롤러 패턴을 한번 적용해보자.

프론트 컨트롤러 도입 V1

V1의 목표는 프론트 컨트롤러가 요청 정보를 받으면 해당 URL에 맞는 컨트롤러를 찾아서 호출해주는 것이다.

머릿속으로 서비스의 프로세스를 그려보자.

  1. FrontController가 서버로 들어오는 모든 요청을 받는다.
  2. 요청된 URI 를 보고 이 요청을 처리할 수 있는 컨트롤러를 찾는다.
  3. 해당 컨트롤러를 호출한다.

우선 요청과 응답을 처리할 FrontController를 만든다. 이놈은 “/members/” 로 시작하는 모든 요청을 처리하도록 만들어야한다.

@WebServlet(name = "frontController", urlPatterns = "/members/*")
public class FrontController extends HttpServlet {}

그리고 들어온 요청에 맞는 컨트롤러를 호출해야하는데, Map을 이용해서 요청으로 조회해서 컨트롤러를 반환받을 것이다. 컨트롤러를 일괄적으로 다룰수 있도록 Controller라는 상위 인터페이스를 하나 만들고 모든 컨트롤러는 이 인터페이스를 구현하도록 만든다.

모든 컨트롤러는 요청에 대한 적절한 처리를 해줘야할 책임이 있기 때문에 이를 위해 process()라는 메서드를 선언한다.

public interface Controller {
    void process(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException;
}

세 개의 컨트롤러가 해당 인터페이스를 구현하도록 만든다.

이제 아까 말한대로 요청에 매핑되는 컨트롤러를 조회할 수 있는 맵을 만든다.

@WebServlet(name = "frontController", urlPatterns = "/members/*")
public class FrontController extends HttpServlet {

    private final Map<String, Controller> controllerMap = new HashMap<>();

    public FrontController() {
        controllerMap.put("/members/form", new MemberFormController());
        controllerMap.put("/members/save", new MemberSaveController());
        controllerMap.put("/members", new MemberListController());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
        String requestURI = request.getRequestURI();
        Controller controller = controllerMap.get(requestURI);
        //매핑되는 컨트롤러가 없으면 404 응답
        if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(request, response);
    }
}

컨트롤러들은 자신의 역할을 그대로 수행한다.

public class MemberFormController implements Controller {

    @Override
    public void process(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

        String viewPath = "/WEB-INF/views/form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

서비스가 제대로 동작하는지 테스트한다.

코드를 보면 유지보수 측면에서 아직 딱히 향상된 점이 없어보인다. 이전에 언급한 중복 문제가 하나도 해결되지 않았다. 기존 코드를 최대한 유지하면서 점진적으로 스프링MVC의 구조를 만들어가는 것이 목표이기 때문에 우선은 프론트 컨트롤러를 도입했다는 것에 의의를 두자.

다음 포스팅에서 조금 더 발전된 형태로 리팩토링 해보도록 하겠다.

728x90
반응형