[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션4 MVC 프레임워크 만들기
◼️ 프론트 컨트롤러 패턴 소개
🟢 FrontController 패턴 특징
- 프론트 컨트롤러 서블릿 하나로 요청을 받음 (나머지 컨트롤러는 서블릿 사용X)
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출
- 입구를 하나로 → 공통 처리 가능
스프링 웹 MVC의 DispatcherServlet이 FrontController 패턴으로 구현되어 있다.
◼️ 프론트 컨트롤러 도입 - v1
프론트 컨트롤러를 단계적 도입해보자.
컨트롤러 인터페이스를 도입한다.
package hello.servlet.web.frontcontroller.v1;
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException;
}
각 컨트롤러는 위 인터페이스를 구현한다. 각 컨트롤러 내부 로직은 기존 서블릿코드와 거의 같다.
회원 폼 컨트롤러
package hello.servlet.web.frontcontroller.v1.controller;
public class MemberFormControllerV1 implements ControllerV1 {
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath); // 컨트롤러->뷰이동
dispatcher.forward(request, response);
}
}
회원 등록 컨트롤러
package hello.servlet.web.frontcontroller.v1.controller;
public class MemberSaveControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void 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);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
회원 목록 컨트롤러
package hello.servlet.web.frontcontroller.v1.controller;
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);
}
}
프론트 컨트롤러
// ../v1의 모든 하위 요청은 서블릿에서 받아들인다.
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerMap = new HashMap<>();
public FrontControllerServletV1() {
// 매핑url, 호출될 컨트롤러
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 타입으로 받을 수 있다.
ControllerV1 controller = controllerMap.get(requestURI); // 실제 호출할 컨트롤러 찾기
if (controller == null) { // Map에 없다면 404 상태코드 반환
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
controller.process(request, response); // 컨트롤러 찾고 해당 컨트롤러 실행
}
}
◼️ View 분리 - v2
모든 컨트롤러에 뷰 이동 부분 코드 중복이 존재한다. 이를 분리하기 위해 별도로 뷰를 처리하는 객체를 만들자.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
MyView 클래스를 만들어준다.
package hello.servlet.web.frontcontroller;
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);
}
}
V2버전 컨트롤러 인터페이스를 만든다. V1과의 차이점은 MyView를 반환하는 것이다.
package hello.servlet.web.frontcontroller.v2;
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
이제 각 컨트롤러는 복잡한 dispatcher.forward() 를 직접 생성해서 호출하지 않고, 단순히 MyView 객체를 생성하고 거기에 뷰 이름만 넣고 반환하면 된다.
회원 등록 폼 컨트롤러
package hello.servlet.web.frontcontroller.v2.controller;
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");
}
}
회원 저장 컨트롤러
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");
}
}
회원 목록 컨트롤러
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");
}
}
프론트 컨트롤러
@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/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); // 컨트롤러 호출 결과 MyView 반환
view.render(request, response); // forward 로직 수행, JSP 실행
}
}
이제 각각의 컨트롤러는 MyView 객체를 생성만 해서 반환하면 된다.
◼️ Model 추가 - v3
현재는 자바 Map을 이용하고 있어, 컨트롤러 입장에서 HttpServletRequest, HttpServletResponse이 꼭 필요하지않다.
컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 변경해보자.
🟢 뷰 이름 중복 제거
아래처럼 중복을 제거하고 단순화된 표현으로 바꿔보자. 폴더 위치가 이동해도 프론트 컨트롤러만 고치도록 바꿔보자.
/WEB-INF/views/new-form.jsp → new-form
위를 위해 아래처럼 수정이 필요하다.
- 컨트롤러는 뷰의 논리 이름을 반환한다.
- 프론트 컨트롤러에서 물리 위치 이름을 지정한다.
🟢 서블릿 종속성 제거
지금까지는 HttpServletRequest의 setAttribute()를 통해 데이터를 저장하고 뷰에 전달했다. 서블릿 종속성을 제거해보자.
Model을 직접 만들고, View이름까지 전달하는 객체를 만들자.
ModelView
public class ModelView {
private String viewName; // 뷰이름
private Map<String, Object> model = new HashMap<>(); // 뷰 렌더링에 필요한 model 객체
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;
}
}
위 모델 뷰는 뷰 이름, 뷰 렌더링에 필요한 모델객체를 가지고 있다.
ControllerV3
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
위 V3 컨트롤러는 서블릿 기술을 전혀 사용하지 않는다.
비교를 위해 이전 V2 컨트롤러를 보자.
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
이전 V2는 단순히 viewpath를 반환했다. V3는 뷰 이름과 뷰에 전달한 Model 데이터를 포함하는 ModelView 객체를 반환한다.
또한 V2는 request.setAttribute()등 서블릿관련메서드를 직접 호출했었다. 반면 V3는 프론트 컨트롤러가 HttpServletRequest가 제공하는 파라미터는 paramMap에 담은 후 V3를 호출한다.
회원 등록 폼 컨트롤러
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form"); //view 논리적 이름지정. 물리이름은 프론트컨트롤러가 처리
}
}
회원 저장 컨트롤러
- 파라미터 정보는 map 에 담겨있다. (프론트컨트롤러가 서블릿 종속성 해결)
- 뷰에 필요한 객체를 ModelView에 담아 반환한다.
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;
}
}
회원 목록 컨트롤러
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;
}
}
프론트 컨트롤러
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/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); // request->paramMap
ModelView mv = controller.process(paramMap); // viewpath,model반환
String viewName = mv.getViewName();
MyView view = viewResolver(viewName); // 실제 viewpath로 변환
view.render(mv.getModel(), request, response); // 렌더링
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
/*
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;
}
}
createParamMap(): HttpServletRequest에서 파라미터 정보를 꺼내서 Map으로 변환
ViewResolver(): 논리 뷰 이름을 실제 물리 뷰 경로로 변경한다.
view.render(): 뷰 객체에 모델정보를 전송해, HTML 화면을 렌더링한다.
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);
}
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);
}
private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request)
{
model.forEach((key, value) -> request.setAttribute(key, value));
}
}
JSP는 request.getAttribute() 로 데이터를 조회한다. 따라서 모델 데이터를 request.setAttribute()로 담아둔다.
그 후 JSP로 포워드 해서 렌더링한다.
정리하면 전체 흐름은 다음과 같다.
- 프론트컨트롤러에서 HttpServletRequest 파라미터를 paramMap에 담는다.
- 해당 URL에 대한 컨트롤러를 호출한다.
- 컨트롤러는 로직을 처리한 후 ModelView를 반환한다 (논리 viewpath + model).
- 프론트컨트롤러에서 viewResolver를 통해 물리 viewpath를 가진 MyView를 생성한다.
- MyView.render(받은ModelView의 모델, request, resonpse)를 호출한다.
- MyView.render는 model map을 request attribute로 변환하여 렌더링한다.
🟢 헷갈려 정리해본 V3에서 각각의 역할
프론트 컨트롤러 (Front Controller)
- 클라이언트의 요청을 받아서 처리할 적절한 컨트롤러를 찾아서 호출
- HttpServletRequest 파라미터를 추출하고, 이를 이용하여 paramMap에 담는다.
- 논리 view path → 물리 view path 변환
컨트롤러 (Controller)
- 실제 비즈니스 로직을 처리
데이터를 받아서 비즈니스 로직을 실행하고, 결과를 ModelView로 반환
ModelView
- 컨트롤러가 처리한 결과를 담는 객체
- 논리적 view path + 모델 데이터
- 뷰에 전달할 모델 데이터를 담고 있어서, 뷰가 이를 활용하여 화면을 렌더링할 수 있도록 한다.
MyView
- 실제로 클라이언트에게 보여지는 뷰를 나타내는 클래스.
- ModelView에 담긴 모델 데이터를 request attribute로 변환
- 받은 모델 데이터를 이용하여 실제 화면을 생성하고, 이를 클라이언트에게 반환
ViewResolver
- 논리적인 view path를 실제 view 객체로 변환하는 역할
◼️ 단순하고 실용적인 컨트롤러 - v4
아래는 컨트롤러 인터페이스이다.
model 객체는 파라미터로 전달하고, 결과는 뷰 이름만 반환한다.
public interface ControllerV4 {
/**
* @param paramMap
* @param model
* @return viewName
*/
String process(Map<String,String> paramMap, Map<String,Object> model);
}
회원 등록 폼 컨트롤러
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
return "new-form";
}
}
회원저장 컨트롤러
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";
}
}
회원목록 컨트롤러
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); // 모델이 파라미터로 전달되기때문에, 모델직접생성X
return "save-result";
}
}
프론트 컨트롤러
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
...
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);
}
◼️ 단순하고 실용적인 컨트롤러 - v4
프론트 컨트롤러는 아래와같이 버전에따라 한 가지 방식의 컨트롤러 인터페이스만 사용가능하다.
프론트컨트롤러에서 다양한 버전의 컨트롤러를 처리할 수 있도록 변경해보자.
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
private Map<String, ControllerV3> controllerMap = new HashMap<>(); // V3 사용
}
MyHandlerAdapter 인터페이스
public interface MyHandlerAdapter {
boolean supports(Object handler); // 어댑터가 해당 handler(컨트롤러) 처리할 수 있는지 판단
//실제 컨트롤러 호출, ModelView반환
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws ServletException, IOException;
}
이제 ControllerV3를 지원하는 어댑터를 구현해보자.
ControllerV3HandlerAdapter
public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) { // ControllerV3 처리할 수 있는 어댑터인지 판단
return (handler instanceof ControllerV3);
}
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) {
ControllerV3 controller = (ControllerV3) handler; //Object->컨트롤러V3으로 형변환
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap); // V3형식에 맞도록 호출
return mv; //컨트롤러V3는 ModelView반환,그대로 ModelView 반환
}
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;
}
}
FrontControllerServletV5
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap(); // 핸들러 맵핑 초기화
initHandlerAdapters(); // 어댑터 초기화
}
private void initHandlerMappingMap() { // url, 해당 컨트롤러 매핑정보
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());
}
private void initHandlerAdapters() { //현재는 V3핸들러어댑터만 존재
handlerAdapters.add(new ControllerV3HandlerAdapter());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Object handler = getHandler(request); // URL 해당 컨트롤러 찾기 ex_MemberSaveControllerV3()
if (handler == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter adapter = getHandlerAdapter(handler); // 핸들러 처리 어댑터 조회 ex_ControllerV3HandlerAdapter()
ModelView mv = adapter.handle(request, response, handler); // 실제 어댑터 호출 ex_ControllerV3HandlerAdapter.handle(..ex_MemberSaveControllerV3())
MyView view = viewResolver(mv.getViewName()); // ModelView->MyView
view.render(mv.getModel(), request, response); // 렌더링
}
// 핸들러 매핑정보에서 URL 에 매핑된 핸들러 객체를 찾아 반환
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI); // url 매핑 컨트롤러 반환
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) { // 핸들러 버전에 맞는 어댑터 반환 ex_핸들러==MemberSaveControllerV3()
return adapter; // 처리가능 어댑터 반환 ex_ControllerV3HandlerAdapter 반환
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler=" + handler);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
◼️ 단순하고 실용적인 컨트롤러 - v5
ControllerV4 를 사용할 수 있도록 기능을 추가해보자. 프론트컨트롤러에 아래를 추가한다.
- ControllerV4 를 사용하는 컨트롤러를 추가
- 해당 컨트롤러를 처리할수 있는 어댑터인 ControllerV4HandlerAdapter 추가
private void initHandlerMappingMap() { // url, 해당 컨트롤러 매핑정보
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());
handlerAdapters.add(new ControllerV4HandlerAdapter()); // V4 추가
}
ControllerV4HandlerAdapter 클래스를 만들어준다. 이때, ControllerV4는 V3와 달리 viewName을 반환한다.
따라서 어댑터에서 ModelView를 만들어, 형식을 맞추어 반환한다.
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
◼️ 정리
지금까지 v1 ~ v5로 점진적으로 프레임워크를 발전시켜 왔다.
- v1: 프론트 컨트롤러를 도입
- v2: 단순 반복 되는 뷰 로직 분리
- v3: Model 추가 (서블릿 종속성 제거,뷰 이름 중복 제거)
- v4: 프론트컨트롤러가 ModelView를 직접 생성 (컨트롤러가 ModelView를 반환 X, ViewName만 반환)
- v5: 유연한 컨트롤러(어댑터 도입)
스프링 MVC는 지금까지 학습한 내용과 거의 같은 구조를 가지고 있다.
'Spring' 카테고리의 다른 글
[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션6 스프링 MVC - 기본 기능 (1) | 2024.04.21 |
---|---|
[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션5 스프링 MVC - 구조 이해 (0) | 2024.04.17 |
[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션3 서블릿, JSP, MVC 패턴 (0) | 2024.04.13 |
[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션2 서블릿 (0) | 2024.04.12 |
[Spring] 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 섹션1 웹 애플리케이션 이해 (0) | 2024.04.12 |