MVC 프레임워크

이정원·2024년 10월 25일
post-thumbnail

FrontController 패턴 특징

프론트 컨트롤러는 서블릿(HttpServlet 상속,@Webservlet())으로 구성되어 다수 클라이언트의 요청에 대해 공통 로직을 처리, URL에 맞는 컨트롤러를 호출하고 나머지 컨트롤러는 서블릿을 사용하지 않는다. 이것이 스프링 웹 MVC의 핵심이고 DispatcherServlet이라 부른다.

1.MVC의 단계별 진화 과정

1-1. v1 - 프론트 컨트롤러 도입

ControllerV1이라는 인터페이스를 구현하여 여러 컨트롤러에서 상속받아 override하여 다양한 컨트롤러를 쉽게 관리하고, 확장성이 높은 구조를 구축한다.

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

이후 각각의 Controller(회원 등록,저장,목록)를 구현하고 FrontController에서 Map에 인터페이스를 선언하여 생성자를 통해 각 컨트롤러를 저장하고 URL 요청에 대한 forward로 JSP를 반환한다.

@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/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();
		ControllerV1 controller = controllerMap.get(requestURI);
 		if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        controller.process(request, response);
    }
 }

단점:모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있고, 깔끔하지 않다.

1-2. v2 - View 분리


컨트롤러에서 바로 forward 하는것이 아닌 객체 MyView를 생성하고 반환하면 프론트 컨트롤러에서 렌더링한다. 이로써 모든 뷰 렌더링 작업이 프론트 컨트롤러에서 통합적으로 관리되기 때문에 일관된 렌더링 방식을 적용할 수 있다.

컨트롤러 인터페이스 v2

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

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

MemberSaveController

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

이후 FrontController에서 반환 받은 view의 render()를 호출한다.

단점: 아직도 컨트롤러에 HttpServletRequest, HttpServletResponse 사용으로 서블릿 종속성과 뷰 이름에 중복이 있다.(ex./WEB-INF/views/new-form.jsp, /WEB-INF/views/save-result.jsp)

1-3. v3 - model 추가


기존 View에서 사용하는 데이터인 Model을 서블릿 기술인 request의 attribute를 사용하였다. 이제 서블릿 종속성을 제거하기 위해 실제 Model 객체를 만들어 구현 컨트롤러에서 논리 뷰의 이름을 반환하면 FrontController에서 처리한다.

ModelView

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

실제 컨트롤러에서 Model 데이터를 ModelVie의 Map에 저장한다.

ControllerV3

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

MemberSaveController

 public class MemberSaveControllerV3 implements ControllerV3 {
 private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @Override
 	public ModelView process(Map<String, String> paramMap) {
 		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;
    }
 }

FrontController 핵심 로직

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

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

위와 같이 FrontController에서 request의 파라미터를 ParamMap에 저장하여 Controller에 넘기면 ModelView 논리 객체가 반환되고 실제 뷰를 생성할 ViewResolver를 통해 실제 뷰 객체를 받고 모델 데이터를 request attribute에 저장하고 JSP로 forward 한다.

단점: 컨트롤러에서 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 번거롭다.

1-4. v4 - 파라미터 model 전달,String 반환


프론트 컨트롤러에서 모델 객체를 파라미터로 컨트롤러에 넘김으로써 저장하는 로직을 구현하지 않아도 되고, 단순 문자열만 반환하여 모델뷰 객체 생성 필요없이 뷰 리졸버에 바로 호출이 가능하다.

1-5. v5 - 어댑터 패턴

만약 개발자가 V3 or V4 둘중 원하는 방식으로 사용하고 싶다면 어떻게 해야할까?

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

두가지 방식은 완전히 다른 인터페이스로써 호환이 불가능하다. 이럴때 어댑터 패턴으로 해결이 가능하다.

MyHandlerAdapter

 public interface MyHandlerAdapter {
 	boolean supports(Object handler);
 	ModelView handle(HttpServletRequest request, HttpServletResponse response, 
Object handler) throws ServletException, IOException;
 }

이후 아래 코드 처럼 MyHandlerAdapter를 상속받아 각각의 컨트롤러에 맞는 어댑터를 구현한다.

 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) {
		//생략
}

어댑터 인터페이스를 구현한 이유는 리스트에 각각 다른 어댑터를 저장하기 위함이다.

 public class FrontControllerServletV5 extends HttpServlet {
 	private final Map<String, Object> handlerMappingMap = new HashMap<>();
 	private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
    //생략
}

FrontController에서 handlerMappingMap에 실제 URL과 해당하는 컨트롤러들을 초기화 하고 구현한 어댑터들을 리스트에 저장한다. 이후 고객의 요청이 들어오면 handlerMappingMap의 Controller를 반환받고 어댑터 리스트를 순회하며 지원하는 Controller의 어댑터를 반환받아 handle을 수행하고 return 값인 ModelView를 통해 viewResolver,렌더링을 실행한다.

Note: Controllerv3와 다르게 Controllerv4는 논리 뷰 이름을 반환한다. 하지만 어댑터 안에서 ModelView 객체로 변환하여 반환하기 때문에 FrontController에서 다양한 반환 타입을 가지는 Controller에 대한 처리가 가능하다.

2.스프링 MVC

2-1.DispatcherServlet

  • DispatcherServlet도 부모 클래스에서 HttpServlet을 상속 받아서 사용하고, 서블릿으로 동작한다.(DispatcherServlet->FrameworkServlet->HttpServletBean->HttpServlet)
  • 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로에 대해서 매핑한다.(자세한 경로가 우선순위 높음)

요청 흐름
1.서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출
2.DispatcherServlet.doDispatch() 호출

전체적인 동작 흐름
1️⃣ 클라이언트가 요청 (/hello)
→ 사용자가 브라우저에서 서버에 요청

2️⃣ DispatcherServlet이 요청을 받음
→ web.xml 또는 Spring Boot에서는 자동 설정됨
→ 요청을 컨트롤러로 전달

3️⃣ 핸들러 매핑(Handler Mapping)이 컨트롤러를 찾음
→ 요청 URL에 맞는 컨트롤러를 찾아줌

4️⃣ 컨트롤러(Controller)가 비즈니스 로직을 처리
→ 예를 들어, @RequestMapping("/hello")가 있는 컨트롤러가 실행됨

5️⃣ 컨트롤러가 ModelAndView 또는 ResponseEntity 반환
→ 뷰 이름 또는 JSON 데이터를 DispatcherServlet에게 전달

6️⃣ 뷰 리졸버(View Resolver)가 뷰를 찾음
→ JSP, Thymeleaf 같은 뷰 템플릿을 찾아 데이터를 바인딩

7️⃣ 최종 응답을 클라이언트에게 전송
→ HTML 페이지, JSON, XML 등의 데이터가 사용자에게 전달됨

2-2.HandlerMapping,HandlerAdapter

핸들러 매핑(Handler Mapping)은 Spring MVC에서 클라이언트의 요청 URL을 적절한 컨트롤러(핸들러)로 매핑해주는 역할을 하는 구성 요소이다.

핸들러 어댑터(HandlerAdapter)는 Spring MVC에서 DispatcherServlet이 핸들러(Controller)를 실행할 수 있도록 도와주는 어댑터 인터페이스이다.

다음은 스프링 부트가 자동 등록한 핸들러 매핑과 어댑터이다.

HandlerMapping

0 = RequestMappingHandlerMapping   : 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
1 = BeanNameUrlHandlerMapping      : 스프링 빈의 이름으로 핸들러를 찾는다.

HandlerAdapter

0 = RequestMappingHandlerAdapter   : 애노테이션 기반의 컨트롤러인 @RequestMapping에서 사용
1 = HttpRequestHandlerAdapter      : HttpRequestHandler 처리
2 = SimpleControllerHandlerAdapter : Controller 인터페이스(애노테이션X, 과거에 사용) 처리

@RequestMapping
가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터는 RequestMappingHandlerMappingRequestMappingHandlerAdapter이다. @RequestMapping의 앞글자를 따서 만든 이름인데, 이것이 스프링에서 주로 사용하는 애노테이션 기반의 컨트롤러를 지원하는 매핑과 어댑터이다.

2-3. 뷰 리졸버

Spring Boot는 JSP 기반의 뷰를 사용할 경우, 자동으로 InternalResourceViewResolver 를 등록한다. 이때, application.properties 에 설정된 값을 활용하여 뷰 리졸버를 구성한다.

핸들러 어댑터가 반환한 논리 뷰 이름은 ViewResolver( InternalResourceViewResolver)를 통해 실제 View 객체로 변환된다. 이후 해당 View 인스턴스의 render() 메서드가 호출되어 최종 HTTP 응답이 생성된다.

스프링 부트가 자동 등록하는 뷰 리졸버

1 = BeanNameViewResolver         : 빈 이름으로 뷰를 찾아서 반환한다. (예: 엑셀 파일 생성 기능
에 사용)
2 = InternalResourceViewResolver : JSP를 처리할 수 있는 뷰를 반환한다.

Thymeleaf 뷰 템플릿을 사용하면 스프링 부트가 ThymeleafViewResolver를 자동으로 등록해준다.

2-4.@Controller

Spring 컨텍스트 초기화 시

  • RequestMappingHandlerMapping은 빈으로 등록된 모든 클래스 중 @Controller 또는 @RequestMapping이 붙은 클래스를 찾는다.
  • 해당 클래스에서 @RequestMapping, @GetMapping, @PostMapping 등이 선언된 메서드를 분석하여 요청 URL과 핸들러 정보를 매핑한다.

클라이언트 요청이 들어오면

  • 요청 URL에 해당하는 핸들러(Controller의 메서드)를 찾아 반환한다.

RequestMappingHandlerMapping은 Map 자료구조를 사용하여 요청 URL과 핸들러(컨트롤러의 메서드) 매핑 정보를 저장한다.

3.로깅

application.properties에서 설정에 따라 레벨 하위의 모든 로그가 출력된다.(INFO 설정시 TRACE 안나옴)

System.out.printLn()은 모든 요청을 출력하기 때문에 실무에서 사용하지 않는다.

로그를 출력할때 아래 코드에서 첫번째 코드는 연산이 일어나 리소스 낭비 때문에 파라미터를 넘기는 두번째 방법을 사용한다.

log.trace("trace my log="+name);
log.trace("trace my log={}",name);

로그는 콘솔이 아닌 파일로 남길수 있으며 네트워크로 전송할수도 있다. 또한 System.out 보다 성능 최적화가 되어 있어 실무에선 로그를 반드시 사용한다.

4.요청 매핑

@RestController: @Controller + @ResponseBody 결합한 형태로써 return값이 문자열일 경우 뷰를 반환하는것이 아닌 HTTP 메세지 바디에 직접 반환된다.(콘텐츠 타입:application/json)

@RequestMapping: 주로 @Controller와 함께 요청 URL 경로,HTTP 메서드를 매핑할 때 사용한다. 클래스 레벨에 매핑 정보를 두면 메서드 레벨에서 해당 정보를 조합해서 사용한다.

@Controller
@RequestMapping("/api")
public class MyController {

    @RequestMapping(value = "/hello", method = RequestMethod.GET) 
    @ResponseBody
    public String sayHello() {
        return "Hello, World!"; 
        // 요청 경로는 api/hello 이다.
    }
}

(@RequestMapping 에 method 속성으로 HTTP 메서드를 지정해야 하기 때문에@GetMapping,@PostMapping,@PutMapping,@DeleteMapping,@PatchMapping 해당 애노테이션을 쓰는것이 직관적이다.)

@ResponseBody: 메서드의 반환값이 문자열, 객체, 리스트 등일 경우 이를 JSON 또는 XML 형식으로 변환해 HTTP 응답의 본문에 직접 포함한다.

4-1.URL 경로,쿼리 파라미터,폼 데이터에서 값을 추출

클라이언트 헤더의 Accept 설정과 서버의 produces로 정의한 값(응답 콘텐츠 타입)은 일치해야 한다.

  • 경로: /mapping/userA
  • 쿼리 파라미터: ?userId=userA

POST - HTML Form

  • content-type: application/x-www-form-urlencoded
  • 메세지 바디에 쿼리 파라미터 형식 전달 username=hello&age=20

@PathVariable: URL 경로에 포함된 값을 메서드의 파라미터로 바인딩하기 위해 사용한다.

@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath(@PathVariable String userId, @PathVariable Long orderId) {
	return "ok";
}

@RequestParam: HTTP 요청의 쿼리 파라미터(URL?username&="java"&age=20), 폼 데이터(Form Data), 또는 URL 경로 파라미터를 메서드의 파라미터로 매핑한다. 기본적으로 @RequestParam은 required=true로 설정되어 있기 때문에 파라미터를 보내지 않으면 예외가 발생한다. 따라서 값이 오던 안오던 @RequestParam(defaultValue = "-1")와 같이 defaultValue 설정이 가능하다.

mapping?username= 이렇게 빈 문자열로 보내도 required=true를 위배하지 않아 주의한다. -> defaultValue는 빈 문자열에도 작동한다.

@ModelAttribute: 실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다. @ModelAttribute는 이 과정을 완전히 자동화해준다.

요청

http://localhost:8080/hello/data?username=jeongwon&id=789

Controller

@GetMapping("data")
public String gogo(@ModelAttribute Hello hello){
        log.info("username={} , userId={}",hello.getUsername(),hello.getId());
        return "ok";
    }

Hello 객체가 생성되고, 요청 파라미터의 값도 모두 들어가 있다.

@RequestParam,@ModelAttribute 둘다 생략이 가능한데, String , int , Integer 같은 단순 타입 = @RequestParam || 나머지 = @ModelAttribute

HTTP 헤더 조회
다음과 같이 여러 값을 받을수 있다.

@RequestMapping("/headers")
public String headers(
        HttpServletRequest request,
        HttpServletResponse response,
        HttpMethod httpMethod,
        Locale locale,
        @RequestHeader MultiValueMap<String, String> headerMap,
        String cookie,
        @RequestHeader("host") String host, //단일 지정 헤더값
        @CookieValue(value = "myCookie", required = false)  String myCookie
) {
    // 메서드 로직
}

4-2.HTTP 요청 메시지

HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우는 @RequestParam, @ModelAttribute 를 사용할 수 없다. 클라이언트는 반드시 POST 메서드를 사용해야 하며,API 요청시 Content-type 헤더를 설정하고 응답 받기 위한 Accept를 지정해야한다. 이를 통해 Spring은 적절한 HttpMessageConverter를 선택하여 요청 및 응답을 처리한다.

HttpEntity: HTTP header, body 정보를 편리하게 조회 (요청 파라미터와 관련 없음)

@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
 		String messageBody = httpEntity.getBody();
    	log.info("messageBody={}", messageBody);
 		return new HttpEntity<>("ok");
}

Json 요청

{"username":"hello","age":"20"};

객체 변환

 HelloData data = objectMapper.readValue(messageBody, HelloData.class);

클라이언트가 JSON으로 보내는 경우 Spring에서 ObjectMapper 클래스를 활용하여 JSON을 Java 객체로 변환(역직렬화, Deserialization)하고, 응답을 보낼 때 Java 객체를 JSON으로 변환(직렬화, Serialization)한다. -> @RequestBody 사용

@RequestBody: HTTP 요청의 본문(Body)에 포함된 데이터(JSON,Text)를 직접 읽어와 메서드의 파라미터 객체로 바인딩할때 사용된다.

@RestController
public class MyController {

    @PostMapping("/text")
    public String handleText(@RequestBody Hello hello) {
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }
}

HttpEntity<>, @RequestBody 를 사용하면 HTTP 메시지 컨버터가 HTTP 메시지 바디의 내용을 원하는 문자나 객체 등으로 변환해준다.HTTP 메시지 컨버터는 문자 뿐만 아니라 JSON도 객체로 변환해준다.

만약 반환 타입 역시 객체로 한다면 HTTP 메세지 컨버터가 해당 객체를 JSON으로 반환해준다.

4-3.Http 응답

응답 방식

  • 정적 리소스(Html,css,js)
    스프링 부트는 다음 디렉토리의 정적 리소스를 제공한다.
    /static,/public,/resources,/META-INF/resources
    src/main/resources 는 시작 경로이다.

  • 뷰 템플릿
    경로:src/main/resources/templates
    컨트롤러 메서드에서 Model model을 파라미터로 받으면 key-value 형태의 데이터를 뷰(템플릿 엔진)로 전달할 수 있다.

  • HTTP 메세지 : @ResponseBody

5.HTTP 메세지 컨버터

Spring에서 컨트롤러 호출 전 HTTP 요청(Request)과 응답(Response)을 변환(Serialize/Deserialize)하는 인터페이스이며 클라이언트가 보낸 데이터를 Java 객체로 변환하여 컨트롤러 파라미터로 넘겨주거나, 컨트롤러 return 값을 JSON, XML 등의 형식으로 변환하여 클라이언트에게 반환하는 양방향 역할을 한다.

스프링 부트 기본 메세지 컨버터
1. ByteArrayHttpMessageConverter: byte[] 데이터를 처리한다.
2. StringHttpMessageConverter: String 데이터를 처리하며 응답시 클라이언트 미디어타입은 text/plain이다.
3. MappingJackson2HttpMessageConverter: 객체 또는 HashMap을 처리하며 클라이언트 미디어타입은 application/json이다.

클라이언트 요청시 컨트롤러 파라미터의 타입과 미디어타입을 체크하여 적절한 메세지 컨버터를 호출한다. (응답은 반환 타입,Accept 체크)

  • @RequestBody 요청
    JSON 요청 -> HTTP 메시지 컨버터 -> 객체 생성 및 필드 바인딩 -> 컨트롤러 파라미터 바인딩
  • @ResponseBody 응답
    객체 -> HTTP 메시지 컨버터 -> JSON 응답

5-1.RequestMappingHandlerAdapter(요청 매핑 핸들러 어댑터)

DispatcherServlet에서 클라이언트의 요청을 받으면 어떻게 애노테이션에 맞는 컨트롤러의 파라미터로 전달할까??

정답은 ArgumentResolver에 있다.

RequestMappingHandlerAdapter는 Spring의 Handler Method를 호출할 때, ArgumentResolver를 호출하여 컨트롤러 파라미터 값(객체)를 생성한다. 파라미터 값이 모두 준비가 되면 컨트롤러를 호출한다.

  • ArgumentResolver: HTTP 메시지 컨버터의 read() 메서드를 사용하여 HTTP 요청 본문에 담긴 데이터를 읽고, 이를 객체로 변환한 후 컨트롤러 메서드의 파라미터에 바인딩한다.

  • ReturnValueHandler: @ResponseBody 애노테이션이 적용된 반환 값의 경우, HTTP 메시지 컨버터의 write() 메서드를 호출하여 객체를 HTTP 응답 본문으로 직렬화한다. 뷰 이름이나 ModelAndView 객체를 반환하는 경우에는 ViewResolver에 의해 해당 뷰 템플릿이 렌더링된다.

0개의 댓글