스프링 MVC 1편 (4), MVC 프레임워크 만들기

2025. 12. 20. 18:31Spring RoadMap/Spring MVC

백엔드 웹 개발 핵심 기술 - MVC 프레임워크 만들기

1. 프론트 컨트롤러(Front Controller) 패턴이란?

이전에는 클라이언트가 각 컨트롤러를 직접 호출했다면, 이제는 수문장(프론트 컨트롤러)이 딱 하나 서서 모든 요청을 먼저 받는다.

공통 로직이 각각의 컨트롤러에 중복되어 존재한다

 

하나의 FrontController로 요청을 받고 호출하는 기능을 맡는다

  • 특징:
    • 프론트 컨트롤러 서블릿 하나가 모든 요청을 받음.
    • 요청을 분석해서 맞는 컨트롤러를 찾아 호출해줌.
    • 공통 처리가 가능함 (한 곳에서 입구를 막고 있으니까!).
    • 나머지 컨트롤러들은 서블릿을 몰라도 됨. 

 

참고: 스프링 웹 MVC의 핵심인 DispatcherServlet이 바로 이 프론트 컨트롤러 패턴으로 구현되어 있다.

 


2. v1: 프론트 컨트롤러 도입

가장 먼저 할 일은 구조를 변경하는 것이다. 클라이언트가 개별 컨트롤러를 직접 부르는 게 아니라, 프론트 컨트롤러가 URL 매핑 정보를 보고 컨트롤러를 호출하도록 바꿀 것이다.

구조 설계

  1. 인터페이스 도입: 프론트 컨트롤러는 어떤 컨트롤러가 호출될지 모른다. 따라서 ControllerV1이라는 인터페이스를 만들고, 모든 컨트롤러가 이를 구현하게 하여 다형성을 활용한다.
  2. 매핑 정보 관리: 프론트 컨트롤러 내부에 Map<String, ControllerV1>을 두고, URL과 컨트롤러 인스턴스를 연결해둔다.

 

ControllerV1 인터페이스

 

먼저 다형성을 위한 인터페이스를 만든다. 모든 컨트롤러는 이제 서블릿(HttpServlet)을 상속받는 게 아니라, 이 인터페이스를 구현하면 된다.

package org.example.springmvcpractice.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

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

 

process(): 서블릿의 service() 메서드와 비슷한 역할을 한다. 프론트 컨트롤러는 이 메서드를 통해 로직을 실행한다.

 

컨트롤러 구현 (회원 등록, 저장, 목록)

이제 인터페이스를 구현한 실제 컨트롤러들을 만들어보자. 기존 로직은 그대로 유지하되, ControllerV1을 구현한다는 점만 다르다.

 

MemberFormControllerV1

package org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ControllerV1;

import java.io.IOException;

public class MemberFormControllerV1 implements ControllerV1 {

    @Override
    public void process(HttpServletRequest request, HttpServletResponse reponse) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, reponse);
    }
}

 

단순히 JSP로 이동(forward)하는 역할만 수행한다.

 

MemberSaveControllerV1

package org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ControllerV1;

import java.io.IOException;

public class MemberSaveControllerV1 implements ControllerV1 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(HttpServletRequest request, HttpServletResponse reponse) throws ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member();
        memberRepository.save(member);

        request.setAttribute("member", member);

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

 

파라미터를 받아 회원을 저장하고, 결과를 뷰로 전달한다.

 

MemberListControllerV1

package org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ControllerV1;

import java.io.IOException;
import java.util.List;

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

    }
}

 

 

이제 프론트 컨트롤러다. 이 서블릿 하나가 모든 요청을 받아서 처리한다.

 

FrontControllerServletV1

package org.example.springmvcpractice.servlet.web.frontcontroller.v1;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ControllerV1;
import org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller.MemberFormControllerV1;
import org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller.MemberListControllerV1;
import org.example.springmvcpractice.servlet.web.frontcontroller.v1.controller.MemberSaveControllerV1;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

// /frontcontroller/v1/ 포함한 모든 하위 요청을 이 서블릿이 받는다.
@WebServlet(name="frontControllerServletV1", urlPatterns = "/frontcontroller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {

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

    public FrontControllerServletV1() {
        // 생성될 때 매핑 정보를 미리 담아둔다.
        controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
        controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
        controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
    }

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

        System.out.println("FrontControllerServletV1.service");
        String requestURI = request.getRequestURI(); // 요청온 URI를 조회한다.

        // Map에서 URI에 맞는 컨트롤러를 찾는다.
        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 없으면 404 에러
            return;
        }

        // 다형성 덕분에 인터페이스의 process()만 호출하면 실제 구현체의 로직이 실행된다.
        controller.process(request, response);
    }
}

 

정리하면

 

  • urlPatterns = "/frontcontroller/v1/*": /frontcontroller/v1/로 시작하는 모든 URL은 이 서블릿이 가로챈다.
  • controllerMap: "이 URL이 오면 저 컨트롤러를 실행해!"라고 미리 약속해둔 지도다.
  • service():
    • request.getRequestURI()로 요청 URL을 알아낸다.
    • Map 뒤져서 컨트롤러(ControllerV1)를 찾는다.
    • controller.process()를 호출한다. 이때 어떤 구현체인지 몰라도 인터페이스만 믿고 호출하면 된다.

 

 

이제 요청이 오면 프론트 컨트롤러가 알아서 알맞은 컨트롤러를 찾아서 실행해준다. 다형성을 활용하여 구현과 관계없이 로직의 일관성을 챙겼다.

 

하지만 코드를 자세히 보면 아직 아쉬운 점이 있다. 모든 컨트롤러에서 뷰로 이동하는 코드(dispatcher.forward)가 중복되고 있다는 점이다. 다음 v2에서는 이 뷰 처리 부분을 깔끔하게 분리해볼 것이다.


3. v2: View 분리 - "단순 반복을 제거하자"

v1을 만들고 나니, 모든 컨트롤러에서 뷰로 이동하는 코드가 똑같이 반복되는 것이 눈에 밟힌다.

// 모든 컨트롤러에 이 코드가 복붙되어 있음...
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

 

이 뷰 렌더링 로직을 별도 객체로 분리해서 깔끔하게 만들어보자.

3-1. MyView 도입

먼저 뷰를 처리하는 역할을 담당할 MyView 객체를 만듭니다.

package org.example.springmvcpractice.servlet.web.frontcontroller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class MyView {
    private String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    // 뷰 렌더링(이동) 로직을 여기에 모아둠
    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

위 MyView가 있으면 각 컨트롤러는 복잡한 dispatcher.forward()를 직접 생성해 호출하지 않아도 된다. 단순히 MyView 객체를 생성 한 후 뷰이름만 넣고 반환하면 된다.

3-2. 컨트롤러 인터페이스 변경 (ControllerV2)

이제 컨트롤러는 직접 forward 하지 않습니다. 대신 MyView 객체를 생성해서 반환하기만 하면 됩니다.

Java
 
package org.example.springmvcpractice.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;

import java.io.IOException;

public interface ControllerV2 {
    // 반환 타입이 void가 아니라 MyView로 변경됨
    MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

3-3. 컨트롤러 구현 변경 

MemberFormControllerV2

package org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

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

 

MemberSaveControllerV2

package org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;

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

 

MemberListControllerV2

package org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.ControllerV2;
import org.example.springmvcpractice.servlet.domain.member.Member;

import java.io.IOException;
import java.util.List;

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

 

코드가 확연히 줄어든 것이 보인다. 이제 컨트롤러는 "어디로 가야 하는지"만 알면 된다.

3-4. 프론트 컨트롤러 (FrontControllerServletV2)

컨트롤러가 던진 MyView를 받아서 실제 렌더링을 지시하는 건 프론트 컨트롤러의 몫입니다.

package org.example.springmvcpractice.servlet.web.frontcontroller.v2;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller.MemberFormControllerV2;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller.MemberListControllerV2;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.controller.MemberSaveControllerV2;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV2", urlPatterns = "/frontcontroller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {

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

    public FrontControllerServletV2() {
        controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
        controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
    }

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

        String requestURI = request.getRequestURI();

        ControllerV2 controller = controllerMap.get(requestURI);
        if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyView view = controller.process(request, response);
        view.render(request, response);
    }
}

 

이제 프론트 컨트롤러가 렌더링까지 일관되게 처리해준다. 각 컨트롤러는 본연의 로직에만 집중할 수 있게 되었다.

 


4. v3: Model 추가 - "서블릿을 몰라도 되게 하자"

v2도 훌륭하지만 아직 두 가지 불편함이 남아있다.

  1. 서블릿 종속성: 컨트롤러가 굳이 HttpServletRequest, HttpServletResponse를 알 필요가 있을까? 파라미터 값만 알면 된다.
  2. 뷰 이름 중복: /WEB-INF/views/랑 .jsp가 계속 반복된다. 경로가 바뀌면 모든 컨트롤러를 수정해야 한다.

4-1. ModelView 도입

컨트롤러가 서블릿 기술(request, response)을 전혀 모르도록 만들기 위해, 데이터와 뷰 이름을 담을 별도의 객체 ModelView를 만든다.

package org.example.springmvcpractice.servlet.web.frontcontroller;

import java.util.HashMap;
import java.util.Map;

public class ModelView {
    private String viewName;
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName) {
        this.viewName = viewName;
    }

    public String getViewName() {
        return viewName;
    }

    public void setViewName(String viewName) {
        this.viewName = viewName;
    }

    public Map<String, Object> getModel() {
        return model;
    }

    public void setModel(Map<String, Object> model) {
        this.model = model;
    }
}

4-2. 컨트롤러 인터페이스 변경 (ControllerV3)

이 부분이 핵심. 인자로 HttpServletRequest가 아니라 단순한 Map을 받는다. 그럼 컨트롤러에서 뷰에 필요한 데이터를 key, value로 넣어주면 된다.

package org.example.springmvcpractice.servlet.web.frontcontroller.v3;

import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;

import java.util.Map;

public interface ControllerV3 {
    ModelView process(Map<String, String> paramMap);
}

4-3. 컨트롤러 구현 변경

MemberFormControllerV3
package org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller;

import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v3.ControllerV3;

import java.util.Map;


public class MemberFormControllerV3 implements ControllerV3 {
    @Override
    public ModelView process(Map<String, String> paramMap) {
        return new ModelView("new-form");
    }
}

 

MemberSaveControllerV2

package org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;
import java.util.Map;

public class MemberSaveControllerV3 implements ControllerV3 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        String username = paramMap.get("username"); // paramMap에서 필요한 요청 파라미터 조회
        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;
    }
}

 

MemberListControllerV2

package org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v2.ControllerV2;

import java.io.IOException;
import java.util.List;
import java.util.Map;

public class MemberListControllerV3 implements ControllerV3 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        List<Member> members = memberRepository.findAll();

        ModelView mv = new ModelView("members");
        mv.getModel().put("members", members);

        return mv;
    }
}

 

MyView

package org.example.springmvcpractice.servlet.web.frontcontroller;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.Map;

public class MyView {
    private String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    // 뷰 렌더링(이동) 로직을 여기에 모아둠
    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }

    // 모델 정보를 함께 받는 새로운 render()를 정의
    public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        modelToRequestAttribute(model, request);
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }

    // 모델의 데이터를 꺼내어 request에 담아둔다.
    private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
        model.forEach((key, value) -> request.setAttribute(key, value));
    }
    
}

 

이제 컨트롤러 코드가 순수한 자바 코드가 되었다. 테스트하기도 쉽고 훨씬 깔끔하다.

4-4. 프론트 컨트롤러 (FrontControllerServletV3)

프론트 컨트롤러의 할 일이 많아졌다.

  1. HttpServletRequest의 파라미터를 다 꺼내서 Map으로 변환해줘야 한다.
  2. 컨트롤러가 반환한 논리 뷰 이름실제 물리 경로로 바꿔줘야 한다(뷰 리졸버).
package org.example.springmvcpractice.servlet.web.frontcontroller.v3;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/frontcontroller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
    private Map<String, ControllerV3> controllerMap = new HashMap<>();

    public FrontControllerServletV3() {
        controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
        controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
        controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
    }

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

        String requestURI = request.getRequestURI();

        ControllerV3 controller = controllerMap.get(requestURI);
        if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(request);
        ModelView mv = controller.process(paramMap);

        String viewName = mv.getViewName();
        MyView view = viewResolver(viewName);
        view.render(mv.getModel(), request, response);
    }

    // HttpServletRequest에서 파라미터 정보를 꺼내에 Map으로 변환한다.
    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) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

이제 뷰 폴더 위치가 바뀌어도 viewResolver 메서드만 고치면 된다.


5. v4: 단순하고 실용적인 컨트롤러 - "개발자가 편해야 한다"

v3는 구조적으로 완벽하다. 하지만 개발자 입장에서 보면 매번 ModelView 객체를 생성해서 반환하는 게 은근히 귀찮다. "그냥 뷰 이름만 String으로 반환하면 안 되나?" 라는 니즈를 반영해보자.

 

5-1. 컨트롤러 인터페이스 변경

ControllerV4

package org.example.springmvcpractice.servlet.web.frontcontroller.v4;

import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;

import java.util.Map;

public interface ControllerV4 {
    /**
     * @param paramMap 파라미터 맵
     * @param model 모델 맵 (값을 담을 빈 그릇을 미리 넘겨줌)
     * @return viewName 뷰의 논리 이름
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

 

리턴 타입이 String으로 바뀌었고, model도 파라미터로 받습니다.

5-2. 컨트롤러 구현 변경 (예: MemberSaveControllerV4)

MemberFormControllerV4
package org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller;

import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;


public class MemberFormControllerV4 implements ControllerV4 {

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        return "new-form"; // 뷰의 논리 이름만 반환하면 된다.
    }
}

"이거지!" 개발자는 이제 로직 짜고 뷰 이름만 던지면 됩니다. 프레임워크가 조금 고생하면 개발자가 편해집니다.

 

MemberSaveControllerV4

package org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller;

import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.Map;

public class MemberSaveControllerV4 implements ControllerV4 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        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";
    }
}

 

MemberListControllerV4

package org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller;

import org.example.springmvcpractice.servlet.domain.member.Member;
import org.example.springmvcpractice.servlet.domain.member.MemberRepository;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.ControllerV4;

import java.util.List;
import java.util.Map;

public class MemberListControllerV4 implements ControllerV4 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        List<Member> members = memberRepository.findAll();
        model.put("members", members);

        return "members";
    }
}

 

FrontControllerServletV4

package org.example.springmvcpractice.servlet.web.frontcontroller.v4;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@WebServlet(name = "frontControllerServletV4", urlPatterns = "/frontcontroller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {

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

    public FrontControllerServletV4() {
        controllerMap.put("/front-controller/v4/members/new-form", new MemberFormControllerV4());
        controllerMap.put("/front-controller/v4/members/save", new MemberSaveControllerV4());
        controllerMap.put("/front-controller/v4/members", new MemberListControllerV4());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse
            response) throws ServletException, IOException {
        String requestURI = request.getRequestURI();

        ControllerV4 controller = controllerMap.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 view = viewResolver(viewName);
        view.render(model, request, response);
    }

    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) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

 


6. v5: 유연한 컨트롤러 (어댑터 패턴) - "확장성을 고려하자"

어떤 프로젝트에서는 v3 방식이 좋고, 어떤 곳에서는 v4 방식이 편할 수 있다. 혹은 나중에 v5 방식을 새로 만들 수도 있다. 지금까지의 프론트 컨트롤러는 한 가지 인터페이스(ControllerV3 또는 ControllerV4)만 고정해서 쓸 수 있었다.

이 문제를 해결하기 위해 어댑터(Adapter) 패턴을 도입한다. 이제 프론트 컨트롤러는 컨트롤러를 직접 호출하지 않고, 어댑터를 통해서 호출한다.

6-1. 핸들러 어댑터 인터페이스 (MyHandlerAdapter)

MyHandlerAdapter
package org.example.springmvcpractice.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;

import java.io.IOException;

public interface MyHandlerAdapter {
    // 1. 이 어댑터가 해당 컨트롤러(핸들러)를 처리할 수 있는지 확인
    boolean supports(Object handler);

    // 2. 실제 컨트롤러를 호출하고, 결과는 무조건 ModelView로 맞춰서 반환
    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}

 

  • boolean supports(Object handler)
    • handler는 컨트롤러를 말한다.
    • 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단하는 메서드다.
  • ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
    • 어댑터는 실제 컨트롤러를 호출하고, 그 결과로 ModelView를 반환해야 한다.
    • 실제 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 ModelView를 직접 생성해서라도 반환해야 한다.
    • 이전에는 프론트 컨트롤러가 실제 컨트롤러를 호출했지만 이제는 이 어댑터를 통해서 실제 컨트롤러가 호출된다.

6-2. 어댑터 구현 

v4 컨트롤러(String 반환)를 호출하더라도, 결과는 v3처럼 ModelView로 포장해서 프론트 컨트롤러에게 돌려줘야 합니다.

ControllerV3HandlerAdapter
package org.example.springmvcpractice.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v3.ControllerV3;
import org.example.springmvcpractice.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.util.HashMap;
import java.util.Map;

public class ControllerV3HandlerAdapter implements MyHandlerAdapter {

    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }

    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        ControllerV3 controller = (ControllerV3) handler;
        Map<String, String> paramMap = createParamMap(request);

        ModelView mv = controller.process(paramMap);
        return mv;
    }

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

ControllerV4HandlerAdapter
package org.example.springmvcpractice.servlet.web.frontcontroller.v5.adapter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.ControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v5.MyHandlerAdapter;

import java.util.HashMap;
import java.util.Map;

public class ControllerV4HandlerAdapter implements MyHandlerAdapter {

    // handler가 ControllerV4인 경우에만 처리
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }

    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) {

        ControllerV4 controller = (ControllerV4) handler;

        Map<String, String> paramMap = createParamMap(request);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        // ControllerV4는 뷰의 이름을 반환하지만 어댑터는 ModelView를 만들어서 반환
        ModelView mv = new ModelView(viewName);
        mv.setModel(model);

        return mv;
    }

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

 

 

6-3. 프론트 컨트롤러 

이제 프론트 컨트롤러는 구체적인 컨트롤러를 몰라도 된다. Object로 다 받는다.

FrontControllerServletV5
package org.example.springmvcpractice.servlet.web.frontcontroller.v5;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.springmvcpractice.servlet.web.frontcontroller.ModelView;
import org.example.springmvcpractice.servlet.web.frontcontroller.MyView;
import org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import org.example.springmvcpractice.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import org.example.springmvcpractice.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/frontcontroller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {

    // ControllerV3, ControllerV4 같은 인터페이스 대신 아무 값이나 받을 수 있는 Object로 변경되었다.
    private final Map<String, Object> handlerMappingMap = new HashMap<>();
    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    // 생성자는 핸들러 매핑과 어댑터를 초기화한다.
    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    private void initHandlerMappingMap() {
        handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
        handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());

        //V4 추가
        handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
        handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
    }

    private void initHandlerAdapters() {
        handlerAdapters.add(new ControllerV3HandlerAdapter());
    }

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

        Object handler = getHandler(request);
        if (handler == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter adapter = getHandlerAdapter(handler);
        ModelView mv = adapter.handle(request, response, handler);

        MyView view = viewResolver(mv.getViewName());
        view.render(mv.getModel(), request, response);
    }

    // handlerMappingMap에서 URL에 매핑된 핸들러(컨트롤러) 객체를 찾아서 반환한다.
    private Object getHandler(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    // handler를 처리할 수 있는 어댑터를 adapter.supports(handler)를 통해 찾는다.
    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter adapter : handlerAdapters) {
            if (adapter.supports(handler)) {
                return adapter;
            }
        }
        throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler=" + handler);
    }

    private MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }
}

정리

v5까지 오면서 구조가 많이 복잡해진 것 같지만, 핵심은 "유연성"이다.

  • 이제 ControllerV3를 쓰든 ControllerV4를 쓰든, 아니면 나중에 @Controller 애노테이션 방식을 쓰든, 어댑터만 갈아 끼우면 기존 코드를 건드리지 않고 확장할 수 있다.
  • 이것이 바로 현재 스프링 MVC(DispatcherServlet)의 실제 구조이다.

이 과정을 통해 우리는 "왜 스프링이 이렇게 생겼는지"를 깊이 이해하게 되었었다.


마무리 정리

 

아무것도 없는 밑바닥에서 시작해서 스프링 MVC의 핵심 구조까지 직접 만들어봤다.

 

v1: 프론트 컨트롤러 도입 (입구를 하나로 통일)

v2: View 분리 (MyView로 뷰 처리 로직 공통화)

v3: Model 추가 (서블릿 종속성 제거, 뷰 이름 중복 제거)

v4: 실용성 개선 (개발자가 쓰기 편하게 뷰 이름만 반환)

v5: 유연성 개선 (어댑터 패턴을 도입해 다양한 컨트롤러 호환)

 

지금 만든 v5 구조가 실제 스프링 MVC의 구조와 거의 정확하게 일치한다. 이 과정을 이해했다면, 앞으로 배울 스프링 MVC의 복잡한 내용도 쉽게 이해할 수 있을 것이다.