후라이

[Spring MVC] MVC 프레임워크 <1> 본문

Spring

[Spring MVC] MVC 프레임워크 <1>

힐안 2024. 12. 19. 19:59

1. MVC 패턴 개요

우리가 만드는 요구사항은 회원저장과 회원목록 조회입니다.

이러한 회원 관리 요구사항을 수행한다고 했을 때,

MVC 패턴을 적용하면 컨트롤러의 역할과, 뷰를 렌더링하는 역할을 분리할 수 있게 되겠죠?

 

우선, 기능 요구사항을 위해 MemberFormController, MemberSaveController, MemberListController를 만들었다는 가정하에, MVC 프레임워크를 밑바닥부터 만들어봅시다.

 

2. 프론트 컨트롤러의 등장

 

 

우선, 프론트 컨트롤러를 설계하게 되면

  • 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받는다.
  • 프론트 컨트롤러가 요청에 맞는 적당한 컨트롤러를 찾아서 호출한다.
  • 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.

이러한 장점들을 가지게 됩니다.

 

3. V1 : 프론트 컨트롤러의 도입

 

정말 태초마을에서는 기능에 따른 컨트롤러를 각자 다 만든 후,  각자 서블릿을 만들어 관리했었는데요,

FrontController 패턴을 만들면 프론트 컨트롤러 서블릿 하나로 클라이언트 요청을 받아들입니다.

즉, 입구를 하나로 만들겠다는 뜻이지요?

 

Version1의 MVC 흐름에 대해 설명하면,

  • 클라이언트로부터 HTTP 요청이 들어옵니다.
  • Front Controller가 들어온 요청에 대해 매핑되는 정보가 있는 지 조회해봅니다. (요청으로 들어온 URL을 찾는다는 뜻)
  • 매핑 후 해당 정보가 있다면, 알맞는 Controller를 호출하면 됩니다. MemberFormController인지, MemberSaveController인지, MemberListController인지 찾는다는 겁니다.
  • 해당 Controller가 호출되면 그 Controller 안에서 .jsp를 forward하여 뷰에 렌더합니다.
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

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

 

우선 인터페이스를 만든 후에 각각의 MemberFormController, MemberSaveController, MemberListController를 만듭니다.

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

 

예시로 회원 목록을 조회하는 코드입니다.

유료 강의이기 때문에 자세한 코드는 넣지 않겠습니다:)

이처럼 Controller 구현체 안에는 각각 jsp의 path에 대해 RequestDispatcher로 렌더하는 과정까지를 담게 됩니다.

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

 

그럼 최종적으로 Front Controller에서는 위에서 매핑 정보들을 저장해두고,

실제 요청이 들어오면 저 controllerMap을 뒤져보게 됩니다. 만약 회원 폼을 작성하도록 요청이 들어오면,  MemberFormController가 호출이 되겠지요!?

 

이제 실제 service 부분에는 서블릿을 통해 request URI를 받고, 그에 맞는 controller를 호출해서 실행하면 됩니다.

(여기서 실행한다는 것은, jsp로 렌더링하는 것까지의 과정을 말합니다.) 

 

 

4. V2 : View의 분리

 

사실, V1에서 했던 로직 자체가 조금은 지저분합니다.

왜냐면 Controller 안에 view를 렌더링하는 부분이 포함되어 있기 때문이에요.

우리는 뷰를 분리해서 코드를 다시 리팩토링해볼 겁니다.

 

 

위 Version1 플로우와 달라진 점이 뭔지 아시겠나요??

원래는 Controller가 JSP를 직접 호출했는데, 이제는 Controller가 MyView라는 것을 반환함으로써 JSP가 호출되는 역할을 MyView에게 위임했습니다. 

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는 viewPath가 들어오면, 해당 Path를 따라 jsp를 view하면 됩니다.

이렇게 됨으로써, Controller 코드 안에는 Dispatcher 부분이 사라지게 됩니다.

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

 

각각의 Controller-process는 MyView를 반환하게 되고, 이때 viewPath를 담아 반환하면 됩니다.

모든 Controller에서 dispatcher를 불러 render하는 과정을 거쳤는데, 이제는 MyView를 반환함으로써 MyView가 jsp에 render하게 되니, 로직이 훨씬 간결해졌죠?

 

Front Controller에서도

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

 

이렇게 컨트롤러의 process로 반환되는 view 값을 불러 render하기만 하면 되겠습니다.

 

 

5. V3 : Model 추가

 

그런데, 위처럼 해놓고 보니까 MemberFormController나, MemberSaveController나, MemberListController 모두

사실 프론트 컨트롤러에서 받아 들어오는 요청 파라미터만 쓰기만 하면 되는데 굳이 계속 process 함수에서 HttpServlet 써서 request랑 response 받고 앉아있는게 불편하게 느껴집니다.

Controller는 사실 서블릿이 필요하지 않은데 계속해서 서블릿에 종속하게 되기도 하구요.

그럼 뭐 어떻게 하냐? -> 요청 파라미터 정보는 자바의 Map으로 넘겨 받는 걸로도 충분합니다.

 

 

일단, 서블릿에 의존하는 것을 완전히 지워버립시다.

Version 2까지에서는 Model을 전달하는 과정 자체도 request.setAttribute()로 데이터를 저장하고 뷰에 전달했습니다.

애초에 이제는 HttpServletRequest도 없기 때문에 Model을 직접 만들고, 추가로 View 이름까지 전달하는 객체를 만들어봅시다.

 

그럼 새로 만들게 되는 Model View에는 대체 뭐가 들어가느냐?

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

 

(Getter, Setter도 필요합니다.)

Model View 안에는 뷰의 이름model 객체를 가집니다.

model은 뷰를 렌더링할 때 필요합니다!

 

model은 단순히 map으로 되어 있으므로 Controller에서 뷰에 필요한 데이터를 key, value로 넣어주도록 설계합니다.

 

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

 

그럼 Controller interface에서는 서블릿 다 필요없고, 그냥 파라미터 맵만 있으면 됩니다.

username, age 같은 파라미터들이 저 paramMap을 통해 불러질 수 있습니다.

 

그럼 똑같이 MemberListController 코드가 어떻게 수정되는지 보면

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

 

일단 ModelView가 반환값으로 설정되고, Model View이름을 "members"라고 합니다.

그리고 방금 만든 ModelView의 model 안에도 "members" 를 키로 하고, 실제 members 리스트를 value로 넣어주면 됩니다.

 

여기서 만든 "members" 뷰 이름은 논리 주소입니다.

원래 물리주소는 "/front-controller/v3/members.jsp" 인데, 경로명을 반복해서 쓰는 게 번거로워서 뷰 이름으로 객체를 생성하도록 했구요,  실제 Front Controller에서 viewResolver를 통해 다시 물리 주소로 변경해서 렌더링하면 됩니다.

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

 

위 코드는 모두 Front Controller에서 작성됩니다.

전체 코드를 제공할 수 없어 흐름을 간략히 설명하자면

 

  • Front Controller에서 Http 요청을 받아, 매핑되는 URL을 찾습니다.
  • request.getRequestURI()를 통해 해당 URL을 찾으면, controllerMap에서 해당 controller를 받아옵니다.
    해당 예시에서는 MemberFormController or MemberSaveController or MemberListController..
  • 그리고 Http 요청에 따라 들어온 파라미터들을 싹 다 해당 controller에게 넘기고 controller.process(paramMap)으로 controller를 호출합니다. 여기서 controller의 반환값은 ModelView였죠?
  • ModelView의 뷰 이름(논리주소)을 불러와서, 이 이름을 viewResolver를 통해 물리주소로 변환합니다.
  • 그리고 변환된 물리주소로 실제 view를 렌더링하면 됩니다.

 

  1. viewName: ModelView 객체의 viewName 필드는 컨트롤러가 처리 결과를 보여줄 뷰의 이름(혹은 논리적 경로)을 의미합니다. 예를 들어 "save-result"는 저장 작업 결과를 보여주는 뷰와 연결된 이름입니다.
  2. model: ModelView 객체의 model 필드는 컨트롤러가 뷰에 넘겨줄 데이터를 담는 곳입니다. 뷰는 이 데이터를 기반으로 화면을 렌더링합니다.

그리고 추가적으로 왜 member나 members를 모델에 저장하는지.. 모델의 개념이 잘 잡히지 않았던지라 추가로 끄적여 봅니다.

mv.getModel().put("members", members) : 

  1. ModelView 객체의 model은 Map<String, Object> 형태입니다. 이 Map은 뷰로 전달할 데이터를 키-값 쌍으로 저장합니다.
  2. mv.getModel().put("members", members)는 "members"라는 키로 Members 객체를 model에 저장하는 코드입니다.
  3. 이렇게 저장된 데이터는 나중에 뷰를 렌더링할 때 사용됩니다. 예를 들어, "show-members"라는 뷰에서는 "member" 키를 통해 저장된 Members 객체를 꺼내와 사용자에게 표시하거나 로직에 활용할 수 있습니다.

 

이 다음에서는 V4, V5로 MVC 프레임워크를 계속 업데이트 해보겠습니다 😊

 

[참고] : 인프런-김영한 < 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 >