04. Front Controller Pattern
이번 시간에는 Front Controller Pattern에 대해 살펴보자.
Front Controller Pattern
front controller
이제까지는 사용자의 요청 하나를 처리하기 위해서 하나의 Servlet을 만들었어야 했다. 하지만 이 과정은 HttpServlet을 상속받는 Servlet을 작성해야하고, get/post에 따라 처리하는 등 상당히 번거롭다.
Servlet은 웹 클라이언트의 요청을 받아서 응답을 출력해야 하는 아주 중요한 역할을 수행한다. MVC 기반 프로그램에서의 Servlet은 여러 개의 모듈로 분리되면서 Controller로써 동작하는데, 대부분의 작업은 Model/View에게 위임하고 실제로는 거의 길잡이 역할만 수행한다.
이렇게 단순히 셔틀 역할을 하는 Servlet을 여러 개 만들 필요는 없다. 차라리 모든 요청을 받아들이는 Servlet 하나만 남겨두고, 필요한 Mode/View를 연결하도록 하면 훨씬 간단해진다. 이런 Servlet을 front controller 라고 하고 이런 작업 방식을 front controller pattern이라고 한다.
front controller pattern은 다음의 장점을 갖는다.
- 단일 진입점: 모든 요청을 front controller에서 처리함으로써 요청 처리의 일관성을 유지한다. 어차피 사용자의 요청은 Thread로 처리되므로 하나의 Servlet이 모두를 처리해도 성능 상 문제가 되지는 않는다.
- 공통처리: 모든 작업은 front controller를 거쳐가므로 필요한 전/후 작업(인증, 권한 검사, 로깅 등)을 일괄적으로 처리할 수 있다. 이로 인해 중복 코드가 줄고 관리가 용이해진다.
- 유연한 확장성: 새로운 요청 처리를 추가할 때 기존의 구조를 크게 변경하지 않고도 쉽게 확장할 수 있다.
- 코드 간결성: 여러 개의 Servlet을 만드는 번거로움이 줄고 코드의 가독성이 높아진다.
이전까지의 방식에서 하나의 servlet은 url mapping을 이용해서 특정 요청과 연결된다. front controller pattern에서는 하나의 Servlet이 모든 요청을 받아서 처리한다고 했는데 그럼 실제 사용자의 요청은 어떻게 구분할 수 있을까?
요청의 구분 1: url에 포함된 특정한 파라미터 이용
가장 기본적인 방법은 /main처럼 url을 고정하고 action과 같은 지정된 parameter로 요청을 구분하는 방법이다.
/main?action=gugudan&dan=3
/main?action=hello
/main?action=add&num1=10&num2=20
이를 위한 FrontController는 다음과 같이 만들어볼 수 있다.
@WebServlet("/main") // 모든 요청은 /main으로 들어온다.
public class FrontControllerByParam extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String action = req.getParameter("action"); // action 이라는 파라미터는 언제나 사용하자.
if(action == null) {
resp.sendRedirect(req.getServletPath());
}else if(action.equals("gugudan")){
System.out.println("구구단 sub controller 사용");
}else if(action.equals("hello")){
System.out.println("Hello sub controller 사용");
}else if(action.equals("add")){
System.out.println("add sub controller 사용");
}
}
}
이 방법은 모든 요청에 지정된 파라미터(여기서는 action 값)을 반드시 삽입해야 한다는 단점이 있다.
요청의 구분 2: 와일드 카드를 이용한 URL 매핑
URL을 작성할 때 특정 URL을 지정할 수도 있지만 와일드카드를 이용한 pattern을 이용해서 여러가지 URL을 동시에 처리할 수 있다.
URL 매핑은 제약이 있는 것의 우선순위가 높다. 즉 /hi/*(1)와 /*(2) 가 있을 때 /hi/hello 요청이 오면 (1)번이 처리한다. 만약 (1)이 없었다면 (2)가 처리하는 형식이다.
다음은 URL 매핑에 사용되는 와일드 카드들이다.
URL 매핑 | 특성 | 매핑 요청 예 |
/* | 모든 요청을 받아들인다. - 기존의 정적 요청에 대한 재정의 이므로 이에 대한 대책이 필요하다. |
/hello, /hello/hi/there, /hello/some.do |
*.do | - do와 같은 확장자 기반 요청을 처리하며 경로는 무관하다. - '/'를 함께 써서 경로를 한정 할 수 없다. |
/hello.do, /hello/hi/there/some.do |
/ | 매핑되지 않은 요청을 모두 처리한다. - 기존의 정적 요청에 대한 재정의 이므로 이에 대한 대책이 필요하다. |
/hello, /hello/hi/there, /hello/some.do |
요청의 구분 2-1: extension 활용
확장자 기반으로 매핑을 작성할 때에는 경로에 상관 없이 확장자가 같은 모든 요청을 처리한다. 참고로 확장자와 경로를 함께 사용할 수 없다. (@WebServlet("/main/*.do") 형태는 불가)
역시 요청 내용을 파악하기 위해서는 servlet path를 사용한다.
@WebServlet("*.do")
public class FrontControllerByExtension extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("요청 경로: "+req.getServletPath());
}
}
위와 같이 FrontController를 만들었을 때 요청 URL과 연결된 servlet path는 다음과 같다.
http://localhost:8080/simpleweb/hello.do -> /hello.do
http://localhost:8080/simpleweb/calc/gugu.do?dan=3 -> /calc/gugu.do
http://localhost:8080/simpleweb/calc/add.do?num1=10&num2=20 -> /calc/add.do
위 호출 결과를 살펴보면 어떤 호출 경로에서 호출하던지 상관 없이 확장자를 기반으로만 동작함을 알 수 있다.
요청의 구분 2-2: url 활용
가장 많이 사용되는 방법은 모든 요청을 처리할 수 있는 URL을 만드는 것이다. 이를 위해 Servlet의 url mapping을 '/'로 해주면 된다. 요청 내용을 확인할 때에는 request 객체의 getServletPath()를 이용한다.
@WebServlet("/")
public class FrontControllerByPath extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("요청 접수: "+req.getServletPath());
}
}
위와 같이 FrontController를 만들었을 때 요청 URL과 연결된 servlet path는 다음과 같다.
http://localhost:8080/simpleweb/hello -> /hello
http://localhost:8080/simpleweb/calc/gugu?dan=3 -> /calc/gugu
http://localhost:8080/simpleweb/calc/add?num1=10&num2=20 -> /calc/add
/*를 사용하면 모든 요청을 받아올 수는 있는데 request 객체에서 servlet path를 받아올 수 없어서 실제로 어떤 경로를 요청하는지 알수가 없다.
This method will return an empty string ("") if the servlet used to process this request was matched using the "/*" pattern.
이런 경우는 getRequestURI()를 써야 하는데 URI는 container root를 포함한 요청 경로여서 추가적인 처리가 필요하다.
참고로 filter에서는 / 형태로 사용할 수 없고 /* 형태로 작성해야 한다.
이제 '/'로 매핑된 front controller가 모든 요청을 가로 채게 된다. 그런데 servlet이나 jsp를 제외한 정적 리소스들(html, css, js 등)은 굳이 servlet이 처리할 필요가 없다. 따라서 이런 요소들은 특별한 경로(static)에 몰아 넣고 default란 특별한 WAS 내장 servlet에게 처리를 요청할 수 있다. 이를 위해 src/main/webapp/WEB-INF/web.xml에 다음의 코드를 추가하자.
<servlet-mapping>
<servlet-name>default</servlet-name>
<url-pattern>/static/*</url-pattern>
</servlet-mapping>
FrontController Pattern 활용 예
이제까지 작성했던 내용을 front controller pattern으로 변경해보자.
FrontController 작성
Front Controller의 url mapping 정보를 '/'로 설정하고 doGet/doPost에서는 전처리(여기서는 logging) 후 request의 servlet path에서 사용자의 의도를 파악해준다. 이후 path 기반으로 처리를 위한 sub controller를 호출해주기만 하면 끝이다.
package com.doding.simpleweb.controller;
...
@WebServlet("/")
public class FrontController extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
logging(req);
String path = req.getServletPath();
if(path.equals("/hello")){
// hello 처리 sub controller 연결
}else if(path.equals("/gugu")){
// 구구단 처리 sub controller 연결
}
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
logging(req);
String path = req.getServletPath();
if(path.equals("/gugu")){
// 구구단 처리 sub controller 연결
}
}
// 전처리
private void logging(HttpServletRequest req){
System.out.printf("요청경로: %s, 요청방식: %s%n", req.getRequestURI(), req.getMethod());
System.out.println("요청 파라미터--------------------------------------------------");
req.getParameterMap().forEach((k,v)->{
System.out.println(k+" : "+ Arrays.toString(v));
});
System.out.println("------------------------------------------------------------");
}
}
Gugu단을 위한 sub controller
sub controller를 만들 때는 유지 보수를 위해서 별도의 파일로 작성하는 것이 좋다. 하지만 여기서는 간단하기도 하고 spring으로 가기 위한 사전 학습 성격이기 때문에 servlet 내부에 만들기로 한다.
sub controller에서는 언제나 3가지 작업을 한다.
- 파라미터 검증
- 1을 바탕으로 비지니스 로직 수행
- 2을 결과를 출력하기 위한 화면 로직(presentation logic) 수행
구구단을 위한 sub controller를 만들어보자. 사실 기존의 GuguServlet에 있는 handleRequest를 가져와서 이름만 gugu로 변경한 것에 불과하다.
private void gugu(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String danStr = request.getParameter("dan"); // 1. 파라미터 확인 및 검증
StringBuilder data = new StringBuilder();
if (danStr == null || danStr.isEmpty()) {
data.append("dan을 입력하세요.");
} else {
int dan = Integer.parseInt(danStr); // 2. 비지니스 로직 처리
for (int i = 1; i < 10; i++) {
data.append(dan).append("*").append(i).append("=").append(i * dan).append("<br>\n");
}
}
response.setContentType("text/html;charset=UTF-8"); // 3. 결과 출력
PrintWriter writer = response.getWriter();
writer.print("""
<html>
<head>
<title>구구단출력</title>
</head>
<body>%s</body>
</html>""".formatted(data));
}
doGet에서는 gugu 메서드를 호출해서 sub controller를 연결하면 끝이다.
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
logging(req);
String path = req.getServletPath();
if(path.equals("/hello")){
// hello 처리 sub controller 연결
}else if(path.equals("/gugu")){
// 구구단 처리 sub controller 연결
gugu(req,resp);
}
}