1. 스프링 MVC 전체 구조
이전에 직접 만든 MVC 프레임워크와 스프링 MVC를 비교해보자. 이전에 만들었던 프레임워크에 대한 내용은 아래를 참고하자.
먼저 이전에 만든 프레임워크의 구조다.
스프링 MVC의 구조는 다음과 같다.
몇 가지 이름만 바뀌었을 뿐이지 똑같다. 차이점은 다음과 같다.
- 프론트 컨트롤러의 이름이 DispatcherServlet으로 변경되었다.
- handlerMappingMap이 HandlerMapping으로 바뀐다.
- MyHandlerAdapter는 HandlerAdapter가 있다.
- ModelView는 스프링에서 ModelAndView로 제공한다.
- viewResolver는 이전에 메서드로 만들었는데 스프링은 interface로 ViewResolver를 만들어놨다.
- MyView는 스프링에서 View라는 interface로 만들어져 있다.
여기서 가장 중요한 DispatcherServlet의 구조를 간단하게 살펴보자. 실제로 코드가 엄청 많은데 핵심코드는 그렇게 많지 않다. 핵심 코드만 잘라서 알아보자. 아래와 같은 코드가 있다.
- org.springframework.web.servlet.DispathcerServlet
스프링 MVC도 프론트 컨트롤러 패턴으로 구현되어 있고, 이 프론트 컨트롤러가 DispatcherServlet이다. 이 DispatcherServlet이 스프링 MVC의 핵심 코드다. DispacherServlet도 일단 서블릿이다. 해당 구현 코드를 보면 다음과 같다.
@SuppressWarnings("serial")
public class DispatcherServlet extends FrameworkServlet {
...
}
위처럼 FrameworkServlet을 상속받고 있는데 이를 타고 올라가면 다음과 같은 코드가 나온다.
@SuppressWarnings("serial")
public abstract class FrameworkServlet extends HttpServletBean implements ApplicationContextAware {
...
}
FrameworkServlet은 HttpServletBean을 상속받는다. 그럼 해당 친구로 다시 올라가보자.
@SuppressWarnings("serial")
public abstract class HttpServletBean extends HttpServlet implements EnvironmentCapable, EnvironmentAware {
...
}
위처럼 "HttpServlet"을 상속받는 것을 알 수 있다. intellij를 통해 다이어그램을 그려보면 다음과 같다.
여러 가지가 있는데, DispatcherServlet위에 FrameworkServlet이 있고, HttpServletBean이 있고, 그리고 HttpServlet이 나온다. 결국 HttpServlet을 DispatcherServlet이 가지고 있는 것이다. 그래서 service() 메서드와 같은 로직들이 다 들어있다. 어쨋든 DispatcherServlet도 부모 클래스에서 "HttpServlet"을 상속받아 사용하고, 서블릿으로 동작한다. 스프링 부트는 "DispatcherServlet"을 서블릿으로 자동으로 등록하고, "모든 경로('urlPatterns="/")"에 대해서 매핑한다. "/"로 넣으면 어떤 하위 경로든 이 서블릿이 호출된다. 단, 더 자세한 경로가 우선순위가 높아서 기존에 등록한 서블릿도 함께 동작하는 것이다.
그래서 요청 흐름은 다음과 같다.
- 서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출된다.
- 스프링 MVC는 이 DispatcherServlet의 부모인 FrameworkServlet에서 service() 메서드를 오라버라이딩 해두었다.
- "FrameworkServlet.service()"를 시작으로 여러 메서드가 호출되고, "DispatcherServlet.doDispatch()"가 호출된다.
먼저 FrameworkServlet의 service() 메서드를 가보자.
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
if (HTTP_SERVLET_METHODS.contains(request.getMethod())) {
super.service(request, response);
}
else {
processRequest(request, response);
}
}
이 서비스 메서드가 호출되면 어쨋든 여러 메서드가 호출되고, 중요한건 결국 DispatcherServlet.doDispatch()가 호출된다는 것이다. 코드는 아래와 같다.
@SuppressWarnings("deprecation")
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
try {
// ModelAndView라는 것이 보인다.
ModelAndView mv = null;
Exception dispatchException = null;
try {
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
// getHandler를 수행한다. 핸들러를 꺼내는 과정이다.
// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
// 핸들러가 없다면 404에러를 세팅하고 바로 리턴한다.
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// 핸들러가 있다면 핸들러 어댑터를 찾는다.
// Determine handler adapter for the current request.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
...
// 이런 코드는 스프링에 인터셉터라는 것이 있는데 그런게 호출되는 것이니, 나중에 따로 살펴보자.
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 핸들러 어댑터로 handle을 호출하여 핸들러가 실행된다. 그리고 그 결과롤 바탕으로 ModelAndView 객체가 생성되면 해당 객체를 받아온다.
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
...
// 이후 processDispatchResult라는 것이 호출된다. 요기서 여러코드가 있는데, 결론적으로 render() 메서드가 호출된다.
// render 메서드 안쪽을 보면 ModelAndView에서 getViewName을 통해 viewName을 가져온다.
// 그리고 viewResolver인 resolveViewName을 통해 view를 가져온다.
// 마지막으로 view.render()를 통해 렌더링한다.
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
...
}
결론적으로 동작 순서는 다음과 같다.
- 핸들러를 조회한다. → 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회한다.
- 핸들러 어댑터를 조회한다. → 핸들러를 실행할 수 있는 핸들러 어댑터를 조회한다.
- 핸들러 어댑터를 실행한다. → 핸들러 어댑터를 실행한다.
- 핸들러를 실행한다. → 핸들러 어댑터가 핸들러를 실행한다.
- ModelAndView를 반환한다. → 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환하여 반환한다.
- ViewResolver를 호출한다. → 뷰 리졸버를 찾고 실행한다.
- View를 반환한다. → 뷰 리졸버가 뷰의 논리 이름을 물리 이름으로 바꿔서 뷰 객체를 찾아 반환해준다.
- View를 렌더링한다. → 뷰를 통해 뷰를 렌더링한다.
이와 같이 스프링 MVC의 강점은 DispatcherServlet의 코드 변경 없이, 원하는 기능을 변경하거나 확장할 수 있다는 점이다. 또한 지금까지 설명한 대부분은 확장 가능하게 인터페이스로 제공한다. 이 인터페이스만 구현하여 DispatcherServlet에 등록만해주면 우리만의 컨트롤러도 만들 수 있다. (그럴 일은 거의 없다.) 일단 주요 인터페이스는 다음과 같다.
- 핸들러 매핑: org.springframework.web.servlet.HandlerMapping
- 핸들러 어댑터: org.springframework.web.servlet.HandlerAdapter
- 뷰 리졸버: org.springframework.web.servlet.ViewResolver
- 뷰: org.springframework.web.servlet.View
2. 핸들러 매핑과 핸들러 어댑터
핸들러 매핑과 핸들러 어댑터에 어떤 것이 있는지 알아보자. 지금은 전혀 사용하지 않지만, 스프링이 어노테이션 기반인 @Controller 이전에 사용했던 간단한 컨트롤러가 있다. 이것을 가져다가 직접 어떻게 되는지 등록해보고, 이를 통해 스프링이 제공하는 핸들러 매핑과 핸들러 어댑터를 이해해보자. 스프링은 과거에 아래와 같은 Controller 인터페이스가 있었다.
- org.springframework,web.servlet.mvc.Controller
public interface Controller {
ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}
지금은 어노테이션 기반의 @Controller를 사용하지만 과거에는 위와 같은 인터페이스를 활용했다. 해당 인터페이스는 @Controller 어노테이션과 전혀다르다. 처리하는 어댑터도 다르고 완전 다른 친구들이다. 일단 간단하게 구현해보자.
// 스프링 빈의 이름을 url 패턴으로 맞추면 해당 url을 요청할 때 이 친구가 호출된다.
@Component("/springmvc/old-controller")
public class OldController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
System.out.println("OldController.handleRequest");
return null;
}
}
이제 브라우저를 통해 아래 요청을 보내자.
- http://localhost:8080/springmvc/old-controller
그럼 터미널에 다음과 같이 원하던 출력이 잘 수행되는 것을 확인할 수 있다.
해당 스트링이 잘 출력되었다는 말은 이 컨트롤러가 호출이 잘 된 것을 의미한다. 이 컨트롤러는 어떻게 호출이 된걸까? 일단 HTTP 요청이 들어온다. 그럼 핸들러 매핑에서 일단 OldController라는 핸들러를 찾아와야 한다. 즉, "스프링 빈의 이름으로 핸들러를 찾을 수 있는 핸들러 매핑"이 필요한 것이다. 핸들러를 찾으면 어댑터가 이 친구를 실행할 수 있는지 확인해야 한다. 즉, 인터페이스인 Controller를 호출할 수 있는 어댑터를 찾아와야 한다. 정리하면 해당 컨트롤러를 실행하기 위해서는 아래 2가지가 필요한 것이다.
- HandlerMapping(핸들러 매핑)
- 핸들러 매핑에서 컨트롤러를 찾아야 한다. 위의 경우 "스프링 빈의 이름으로 핸들러를 찾을 수 있는 핸들러 매핑이 필요하다."
- HandlerAdapter(핸들러 어댑터)
- 핸들러 매핑을 통해 찾은 핸들러를 실행할 수 있는 핸들러 어댑터가 필요하다. 즉, 위 경우 Controller 인터페이스를 실행할 수 있는 핸들러 어댑터를 찾아서 실행시켜야 한다.
스프링은 이미 필요한 핸들러 매핑과 핸들러 어댑터를 대부분 구현했다. 따라서 개발자가 직접 핸들러 매핑과 핸들러 어댑터를 만들 일은 거의 없다. 스프링 부트를 쓰면 자동으로 핸들러 매핑과 핸들러 어댑터를 여러 가지를 등록해준다. 먼저 핸들러 매핑에서 대표적인 것은 다음과 같다. (숫자는 우선 순위를 의미한다.)
- 0 = RequestMappingHandlerMapping: 어노테이션 기반의 컨트롤러인 @RequestMapping에서 사용한다.
- 1 = BeanNameUrlHandlerMapping: 스프링 빈의 이름으로 핸들러를 찾는다.
이전의 경우 0순위인 @RequestMapping으로 조회해보고 등록된 것이 없었으므로 그 다음 순위인 스프링 빈의 이름으로 요청을 조회한다. 그런데 해당 컨트롤러가 존재하므로 핸들러를 끄집어 내는 것이다. 그리고 이 핸들러를 처리할 수 있는 핸들러 어댑터를 찾아야한다. 이를 위해 스프링 MVC는 HandlerAdapter를 찾는다. 대표적인 것은 다음과 같다.
- 0 = RequestMappingHandlerAdapter: 어노테이션 기반의 컨트롤러인 @RequestMapping에서 사용한다.
- 1 = HttpRequestHandlerAdapter: HttpRequestHandler(인터페이스) 처리
- 2 = SimpleControllerHandlerAdapter: Controller 인터페이스(어노테이션 x, 과거에 사용) 처리
핸들러 어댑터 역시 우선 순위 순서로 찾고, 없는 경우 다음 우선 순위로 넘어가서 탐색한다. 어쨋든 정리하면 다음과 같은 순서로 작동하게 된다.
- 핸들러 매핑으로 핸들러를 조회한다. 단, HandlerMapping을 순서대로 실행해서 찾는다. 위 경우 사용한 HandlerMapping은 BeanNameUrlHandlerMapping이다.
- 핸들러 어댑터를 찾는다. 이때 HandlerAdapter의 supports()를 순서대로 호출해본다.
- 핸들러 어댑터를 실행한다. 위 경우 Controller 인터페이스를 사용했으므로 SimpleControllerHandlerAdapter를 실행할 것이다.
3. View Resolver
뷰 리졸버를 알아보기 위해 이전에 만들었던 OldController에서 ModelAndView 객체를 리턴하게 하자.
@Component("/springmvc/old-controller")
public class OldController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
System.out.println("OldController.handleRequest");
return new ModelAndView("new-form");
}
}
이제 서버를 실행시켜보고, 올바른 HTTP 명령을 보내면 아래와 같이 오류 페이지가 뜬다.
이 컨트롤러는 호출됐는데 뷰를 못 찾는 것이다. 이를 위해 우리가 viewResolver를 만들어줘야 한다. 우리가 스프링 부트를 쓰기 때문에 application.properties에 아래 내용을 작성하면 된다.
spring.mvc.view.prefix = /WEB-INF/views/
spring.mvc.view.suffix = .jsp
그럼 아래와 같이 올바르게 화면을 띄워준다.
스프링 부트는 어플리케이션이 올라올 때 여러가지를 자동으로 등록한다. 그중에서 viewResolver인 InternalResourceViewResolver도 자동으로 등록한다. 이를 등록할 때 그냥 등록하는 것이 아니고, application.properties에서 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 사용하여 등록한다. 실제로 아래와 같은 기능을 자동으로 해주는 것이다.
@Bean
ViewResolver internalResourceViewResolver() {
return new InternalResourceViewResolver("/WEB-INF/views/", ".jsp");
}
그래서 뷰 리졸버의 동작 방식을 살펴보자. 이전에 살펴본 것과 같이 HTTP 요청을 하면 핸들러 매핑을 통해 핸들러를 찾고, 핸들러를 처리할 수 있는 핸들러 어댑터를 찾는다. 그리고 핸들러 어댑터를 실행하여 핸들러 어댑터가 핸들러를 실행시키고 그 결과를 ModelAndView로 변환하여 DispatcherServlet에 전달한다. 여기에서 논리적 이름으로 viewResolver가 호출된다. 그러면 viewResolver가 호출될 때 스프링부트가 자동으로 여러 viewResolver를 등록해준다. 그중에서 두 가지만 살펴보자.
- 1 = BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환한다.
- 2 = InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환한다.
그럼 우리 코드는 다음과 같은 방식으로 동작하는 것이다.
- 핸들러 어댑터를 통해 view의 논리 이름을 얻는다.
- ViewResolver를 호출한다. 위 예시를 가지고 보자. 일단 "new-form"이라는 뷰 이름을 통해 viewResolver를 순서대로 호출한다. 일단 "BeanNameViewResolver"를 통해 "new-form"이라는 이름의 스프링 빈으로 등록된 뷰를 찾는다. 하지만 없으므로 "InternalResourceViewResolver"가 호출된다.
- InternalResourceViewResolver는 InternalResourceView를 반환한다. InternalResourceView는 jsp처럼 forward()를 통해 처리할 수 있는 경우 사용한다.
- view.render()가 호출되고, InternalResourceView는 forward()를 통해 JSP를 실행한다.
다음 내용은 참고 사항이다.
- InternalResourceViewResolver는 JSTL 라이브러리가 있으면 InternalResourceView를 상속받은 JstlView를 반환한다. JstlView는 JSTL 태그 사용시 약간의 부가 기능을 제공한다.
- 다른 뷰의 경우 실제 뷰를 렌더링하고, JSP는 forward()를 통해 JSP로 이동해야 랜더링 된다. JSP가 아닌 다른 뷰 템플릿은 forward() 과정 없이 바로 랜더링된다.
- Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 한다. 최근에는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동으로 해준다.
4. 스프링 MVC - 시작하기
스프링이 제공하는 컨트롤러는 어노테이션 기반으로 동작한다. 그래서 매우 유연하고 실용적이다. 과거에는 자바 언어에 어노테이션이 없었다. 위에서 봤듯이 인터페이스 기반으로 제공을 했다. 그러다가 @RequestMapping이 등장한다.
- @RequestMapping: 스프링은 어노테이션을 활요한 매우 유연하고, 실용적인 컨트롤러를 만들었는데 이것이 바로 @RequestMapping이다. 어쨋든 그럼 @RequestMapping을 인식해서 찾아줘야 한다. 요청이 오면 handlerMapping이라는 곳에서 핸들러를 찾아줘야 한다. 즉, 우리의 컨트롤러를 누군가 찾아야 한다. 그리고 그 찾은 컨트롤러를 실행해주는 핸들러 어댑터가 필요하다. 이것을 스프링은 "RequestMappingHandlerMapping"과 "RequestMappingHandlerAdapter" 두 가지를 통해 지원한다. 이 둘은 가장 우선 순위가 높은 핸들러 매핑과 핸들러 어댑터다.
이제 @RequestMapping을 통해 스프링 MVC 컨트롤러로 기존에 만들었던 프레임워크를 변경해보자. 먼저 기본 HTTP Form을 제공하는 view를 얻기 위한 핸들러 코드다.
@Controller
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
- @Controller
- 스프링이 자동으로 스프링 빈으로 등록한다. 왜냐하면 @Controller안에 @Component가 있기 때문이다.
- 스프링 MVC에서 해당 어노테이션이 붙어있다면 어노테이션 기반 컨트롤러로 인식한다. 즉, @Controller라는 것이 있으면 RequestMappingHandlerMapping에서 이건 핸들러 정보인지를 판단하여 꺼낼 수 있는 대상이 된다.
- @RequestMapping
- 요청 정보를 매핑한다. @RequestMapping에 작성된 URL이 호출되면 해당 어노테이션 아래에 있는 메서드가 호출된다. 어노테이션 기반으로 동작하므로 메서드 이름은 임의로 지으면 된다.
- ModelAndView: 모델과 뷰 정보를 담아서 반화하면 된다.
RequestMappingHandlerMapping은 "스프링 빈"중에서 @Controller가 "클래스 레벨에 붙어있는 경우" 매핑 정보로 인식한다. 그래서 아래 핸들러 코드와 위의 핸들러 코드는 동일하게 동작한다. RequestMappingHandlerMapping의 isHandler() 메서드를 보면 다음과 같이 Controller.class에 속하는 친구들만을 핸들러로 인식한다.
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
다음으로 회원 저장을 처리하는 핸들러는 다음과 같다.
@Controller
public class SpringMemberSaveControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/springmvc/v1/members/save")
public ModelAndView process(HttpServletRequest request, HttpServletResponse response) {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
}
회원 목록 조회와 관련된 핸들러는 다음과 같다.
@Controller
public class SpringMemberListControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/springmvc/v1/members")
public ModelAndView process() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
}
5. 스프링 MVC - 컨트롤러 통합
@RequestMapping은 클래스 단위가 아닌 메서드 단위로 적용된다. 따라서 컨트롤러 클래스를 하나로 통합할 수 있다. 우리가 위에서 만들었던 컨트롤러들을 통합하면 아래와 같아진다.
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
@RequestMapping
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
}
- @RequestMapping("/springmvc/v2/members"): 중복되는 url은 class 위에 @RequestMapping에 작성해주면 해당 url이 다른 컨트롤러의 주소 앞에 붙어서 요청을 받게된다.
6. 스프링 MVC -실용적인 방식
MVC 프레임워크를 만들면서 ControllerV3는 ModelView를 개발자가 직접 생성하여 반환했기 때문에 불편했었다. 따라서 뷰의 논리적 이름만 반환하는 ControllerV4를 통해 이를 개선했었다. 스프링 MVC 역시 개발자가 편리하게 개발할 수 있는 기능을 제공한다.
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
// 어노테이션 기반의 컨트롤러는 ModelAndView를 반환해도 되고, 문자열를 반환해도 된다. 그러면 그냥 뷰 이름인 줄 알고 프로세스가 진행된다.
@GetMapping(value = "/new-form")
public String newForm() {
return "new-form";
}
@PostMapping(value = "/save")
public String save(
@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
- 위와 같이 컨트롤러는 ModelAndView가 아니라 String을 반환해도 된다. 이 경우 String을 그냥 뷰 이름으로 판단하여 처리한다. 물론, 뷰 이름으로 보지 않도록 하는 방법도 있다.
- @GetMapping, @PostMapping: Get 혹은 Post 방식으로 받겠다는 말이다. 만약 해당 어노테이션을 사용하지 않는다면, @RequestMapping(value = "/new-form", method = RequestMethod.GET), @RequestMapping(value = "/save", method = RequestMethod.Post)와 같이 작성해야 한다.
- @RequestParam: 위처럼 request.getParameter("username")혹은 request.getParameter("age")를 사용하지 않고 @RequestParam을 사용하면 가져올 수 있다. GET 쿼리 파라미터, POST Form 방식 모두 지원한다.
사실 @GetMapping을 열어보면 @RequestMapping이 아래처럼 작성되어 있다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping { ... }
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring MVC-1] 스프링 MVC - 웹 페이지 만들기 (0) | 2023.09.24 |
---|---|
[Spring MVC-1] 스프링 MVC - 기본 기능 (0) | 2023.09.23 |
[Spring MVC-1] MVC 프레임워크 만들기 (0) | 2023.09.19 |
[Spring MVC-1] 서블릿, JSP, MVC 패턴 (0) | 2023.09.12 |
[Spring MVC-1] 서블릿 (0) | 2023.09.09 |