앞서 MVC 패턴의 구현 방법을 설명할 때 컨트롤러 서블릿이 해야 할 두 번째 작업은 사용자가 어떤 기능을 요청했는지 분석하는 것이었다. 예를 들어, 사용자가 게시판의 글 목록 보기를 요청했는지, 게시판의 글쓰기를 요청했는지, 아니면 게시판의 답글 쓰기를 요청했는지 알아야 컨트롤러가 알맞게 모델의 기능을 수행할 수 있다.
사용자가 어떤 기능을 요구했는지 판단하기 위해 사용하는 방법 중에서 가장 일반적인 방법은 명령어를 이용하는 것이다. 즉, 사용자는 "게시판글목록보기" 명령, "게시판글쓰기" 명령 등을 컨트롤러 서블릿에 전송하며, 컨트롤러 서블릿은 사용자가 전송한 명령어에 해당하는 기능을 수행한 후 뷰를 통해서 결과를 보여주는 방식을 사용하는 것이다.
웹 브라우저를 통해서 명령어를 전달하는 방법은 다음의 두 가지가 있다.
파라미터를 이용하는 방법은 특정한 이름의 파라미터에 명령어 정보를 담아서 전달하는 것이다. 예를 들어, 다음과 같이 cmd 파라미터에 명령어를 전달하는 것이다.
ControllerServlet은 cmd 파라미터 값에 따라 알맞은 기능을 수행하도록 작성한다. 즉, ControllerServlet은 다음과 같은 코드를 갖게 된다.
String command = request.getParameter("cmd");
if(command == null){
// 명령어 오류 처리
}else if(command.equals("BoardList")){
// 글 목록 읽기 요청 처리
}else if(command.equals("BoardWriteForm")){
// 글쓰기 입력 폼 요청 처리
컨트롤러 서블릿은 명령어에 알맞은 로직 코드를 실행한 후 결과를 보여줄 뷰로 이동해야 하므로, 다음과 같이 로직 코드를 실행한 뒤 결과를 보여줄 뷰 페이지를 지정하게 된다.
String command = request.getParameter("cmd");
String viewPage = null;
if(command == null) {
// 명령어 오류 처리
viewPage = "/error/invalidCommand.jsp";
}else if(command.equals("BoardList")){
// 글목록 읽기 요청 처리
...
viewPage = "/error/board/list.jsp";
}else if(command.equals("BoardWriteForm")){
// 글쓰기 입력 폼 요청 처리
...
viewPage = "/board/writeForm.jsp";
}
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPage);
dispatcher.forward(request, response);
위 코드는 명령어와 관련된 로직 처리를 컨트롤러 서블릿에서 처리하고 있는데, 이렇게 되면 서블릿 코드가 복잡해진다. 위 코드에서는 단순하게 한 줄로("..."으로) 로직 코드 부분을 표시했지만, 실제로는 몇 줄에서 수십 줄에 이르기까지 로직 코드 부분이 길어지게 된다.
이런 복잡함을 줄이는 방법은 각 명령어에 해당하는 로직 처리 코드를 별도의 클래스로 작성하는 것인데, 이런 패턴을 커맨드(Command) 패턴
이라고 부른다. 커맨드 패턴은 하나의 명령어를 하나의 클래스에서 처리하도록 구현하는 패턴
으로서, 커맨드 패턴을 사용하면 앞서 코드를 다음고 같은 형태로 바꿀 수 있다.
String command = request.getParameter("cmd");
CommandHandler handler = null;
if(command == null){
handler = new NullHandler();
}else if(command.equals("BoardList")){
handler = new BoardListHandler();
}else if(command.equals("BoardWriteForm")){
handler = new BoardWriteFormHandler();
}
String viewPage = handler.process(request, response);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPage);
dispatcher.forward(request, response);
CommandHandler는 인터페이스이며, NullHandler, BoardListHandler 등의 클래스는 각각의 명령어에 해당하는 로직 실행 코드를 담고 있는 클래스이다. 이 로직 처리 클래스는 로직을 수행하기 위해 process() 메서드를 호출하고 결과를 보여줄 뷰 페이지 정보를 리턴한다. 즉, 컨트롤러 서블릿은 명령어에 해당하는 CommandHandler 인스턴스를 생성하고, 실제 로직의 처리는 생성한 CommandHandler 인스턴스에서 실행되는 구조가 되는 것이다.
커맨드 패턴에서 명령어를 처리하는 클래스는 아래 그림과 같은 공통 인터페이스를 상속해서 구현한다.
이 절에서는 모든 명령어 핸들러가 동일 인터페이스를 상속받아 구현하는 형태를 사용한다. 이 절에서 사용할 공통 인터페이스는 아래 예제 코드와 같다.
package mvc.command;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public interface CommandHandler {
public String process(HttpServletRequest req, HttpServletResponse res)
throws Exception;
}
명령어를 처리하는 핸들러 클래스는 CommandHandler 인터페이스를 구현하면 된다. 핸들러 클래스의 process() 메서드는 다음과 같은 세 가지 작업을 처리한다.
public class SomeHandler implements CommandHandler{
public String process(HttpServletRequest request, HttpServletResponse response){
// 1. 명령어와 관련된 비즈니스 로직 처리
...
// 2. 뷰 페이지에서 사용할 정보 저장
request.setAttribute("somValue", value);
...
// 3. 뷰 페이지의 URI 리턴
return "/view/someView.jsp";
}
}
첫 번째 단계에서는 사용자가 요청한 작업을 수행한다. 보통 모델을 사용해서 비즈니스 로직을 처리하는 코드가 위치한다. 두 번째 단계에서는 뷰 페이지가 요청 처리 결과를 보여줄 때 필요한 객체를 request 기본 객체의 속성에 저장한다. 마지막으로, 처리 결과를 보여줄 뷰 페이지를 리턴한다.
컨트롤러 서블릿은 process() 메서드의 실행 결과로 리턴받은 뷰 페이지의 URI를 사용해서 뷰 페이지로 이동하며, 뷰 페이지는 두 번째 단계에서 저장한 request의 속성값을 읽어와 결과를 보여주게 된다.
로직 처리 코드를 컨트롤러 서블릿에서 핸들러 클래스로 옮겼지만, 여전히 컨트롤러 서블릿은 명령어에 따른 알맞은 처리를 하기 위해 다음과 같이 중첩된 if-else 구문을 사용해야 한다.
String command = request.getParameter("cmd");
CommandHandler handler = null;
if(command == null){
handler = new NullHandler();
}else if(command.equals("BoardList")){
handler = new BoardListHandler();
}else if(command.equals("BoardWriteForm")){
handler = new BoardWriteFormHandler();
}
이 코드는 새로운 명령어가 추가되면 컨트롤러 서블릿 클래스의 코드를 직접 변경해야 한다는 단점이 있다. 이 단점을 해결하는 방법은 <명령어, 핸들러 클래스>의 매핑 정보를 설정 파일에 저장하는 것이다. 즉, 다음과 같은 설정 파일을 사용하는 것이다.
BoardList = mvc.command.BoardListHandler
BoardWriteForm = mvc.command.BoardWriteFormHandler
설정 파일의 한 줄은 '명령어 = 핸들러클래스이름' 형태로 구성된다. 컨트롤러 서블릿은 설정 파일에서 명령어와 핸들러 클래스의 매핑 정보를 읽어와 명령어에 해당하는 핸들러 클래스 객체를 미리 생성해두었다가 process() 메서드에서 사용하면 된다.
컨트롤러 서블릿에서 설정 파일을 읽어오기에 가장 좋은 위치는 init() 메서드이다. init() 메서드는 서블릿을 생성하고 초기화할 때 호출되는 메서드이다. 설정 파일을 이용해서 핸들러 객체를 생성하는 기능을 구현한 컨트롤러 서블릿은 아래 예제 코드와 같이 작성할 수 있다.
package mvc.controller;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import mvc.command.CommandHandler;
import mvc.command.NullHandler;
public class ControllerUsingFile extends HttpServlet {
// <커맨드, 핸들러인스턴스> 매핑 정보 저장
private Map<String, CommandHandler> commandHandlerMap =
new HashMap<>();
public void init() throws ServletException {
// configFile 초기화 파라미터를 읽어온다.
// 이 값을 이용해서 설정 파일 경로를 구한다.
String configFile = getInitParameter("configFile");
Properties prop = new Properties();
String configFilePath = getServletContext().getRealPath(configFile);
// 설정 파일로부터 매핑 정보를 읽어와 Properties 객체에 저장한다.
// Properties는 (이름, 값) 목록을 갖는 클래스로서,
// 이 경우에는 프로퍼티 이름을 커맨드 이름으로 사용하고
// 값을 클래스 이름으로 사용한다.
try (FileReader fis = new FileReader(configFilePath)) {
prop.load(fis);
} catch (IOException e) {
throw new ServletException(e);
}
// Properties에 저장된 각 프로퍼티의 키에 대해 다음 작업을 반복한다.
Iterator keyIter = prop.keySet().iterator();
while (keyIter.hasNext()) {
// 1. 프로퍼티 이름을 커맨드 이름으로 사용한다.
String command = (String) keyIter.next();
// 2. 커맨드 이름에 해당하는 핸들러 클래스 이름을 Properties에서 구한다.
String handlerClassName = prop.getProperty(command);
try {
// 3. 핸들러 클래스 이름을 이용해서 Class 객체를 구한다.
Class<?> handlerClass = Class.forName(handlerClassName);
// 4. Class로부터 핸들러 객체를 생성한다.
CommandHandler handlerInstance =
(CommandHandler) handlerClass.newInstance();
// 5. commandHandlerMap에 (커맨드, 핸들러 객체)에 대한 매핑 정보를 저장한다.
commandHandlerMap.put(command, handlerInstance);
} catch (ClassNotFoundException | InstantiationException
| IllegalAccessException e) {
throw new ServletException(e);
}
}
}
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
process(request, response);
}
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
process(request, response);
}
private void process(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
// 클라이언트가 요청한 명령어를 구한다. cmd 파라미터를 명령어로 사용한다.
String command = request.getParameter("cmd");
// commandHandlerMap에서 요청을 처리할 핸들러 객체를 구한다.
// cmd 파라미터 값을 키로 사용한다.
CommandHandler handler = commandHandlerMap.get(command);
// 명령어에 해당하는 핸들러 객체가 존재하지 않을 경우 NullHandler를 사용한다.
if (handler == null) {
handler = new NullHandler();
}
String viewPage = null;
try {
// 구한 핸들러 객체의 process() 메서드를 호출해서 요청을 처리하고,
// 결과로 보여줄 뷰 페이지를 리턴 값으로 전달받는다.
// 핸들러 인스턴스인 handler의 process() 메서드는
// 클라이언트의 요청을 알맞게 처리한 후,
// 뷰 페이지에 보여줄 결과값을 request나 session의
// 속성에 저장해야 한다.
viewPage = handler.process(request, response);
} catch (Throwable e) {
throw new ServletException(e);
}
// viewPage가 null이 아닐 경우, 핸들러 인스턴스가 리턴한 뷰 페이지로 이동한다.
if (viewPage != null) {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPage);
dispatcher.forward(request, response);
}
}
}
Properties와 프로퍼티 파일
자바의 Properties는 프로퍼티를 관리할 때 사용하는 클래스이다. 프로퍼티는 (이름, 값)으로 구성된다. Properties 클래스의 기본 사용법은 다음과 같다.
Properties prop = new Properties();
prop.setProperty("name1", "value1"); // 이름이 name 1이고 값이 value1인 프로퍼티 설정
String name1 = prop.getProperty("name1"); // name1 프로퍼티 값 구함
Properties 클래스는 (이름, 값) 목록을 파일에서 읽어올 수 있다. 이때 이 파일을 프로퍼티 정보를 갖고 있다고 해서 프로퍼티 파일이라고도 한다. 프로퍼티 파일은 다음과 같은 형식을 갖는다.
# 주석
프로퍼티이름1=프로퍼티값
프로퍼티이름2=프로퍼티값2
#으로 시작하는 줄은 주석으로 처리하고, 한 줄에 한 개의 프로퍼티 이름과 값을 설정한다. 프로퍼티 이름과 값은 등호 기호(=)를 사용해서 구분한다.
Properties의 load() 메서드를 사용하면 설정 파일로부터 프로퍼티 정보를 읽어올 수 있다.
NullHandler 클래스는 404 에러를 응답으로 전송하는 핸들러 클래스로서 코드는 다음과 같이 간단하다.
package mvc.command;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class NullHandler implements CommandHandler{
@Override
public String process(HttpServletRequest req, HttpServletResponse res) throws Exception {
res.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
}
CommonUsingFile 서블릿은 명령어에 해당하는 핸들러 객체를 구하기 위해서 다중 if-else 블록을 사용하지 않는다. 이 절차는 commandHandlerMap에서 핸들러 객체를 구하는 과정으로 단순해졌다. 또한, 로직 코드는 핸들러 객체에서 처리한다.
configFile 초기화 파라미터를 설정 파일 경로로 사용하므로, web.xml 파일에 아래 코드와 같이 <init-param> 태그를 통해서 설정 파일 경로를 지정한다.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>ControllerUsingFile</servlet-name>
<servlet-class>mvc.controller.ControllerUsingFile</servlet-class>
<init-param>
<param-name>configFile</param-name>
<param-value>/WEB-INF/commandHandler.properties</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>ControllerUsingFile</servlet-name>
<url-pattern>/controllerUsingFile</url-pattern>
</servlet-mapping>
</web-app>
위의 예제 코드처럼 configFile 초기화 파라미터에 설정 파일의 경로를 지정했다면, ControllerUsingFile 서블릿은 init() 메서드에서 /WEB-INF/commandHandler.properties 파일로부터 설정 정보를 읽어와 <명령어, 커맨드 핸들러 객체> 매핑 정보를 생성한다.
명령어 매핑 정보 파일은 아래 코드와 같이 작성하면 된다.
hello=mvc.hello.HelloHandler
#someCommand=any.SomeHandler
설정 파일에서 첫 글자가 '#'로 시작하면 주석으로 처리된다. 즉 2번째 행은 주석으로 처리된다. 1번째 행은 명령어 'hello'를 처리하는 핸들러가 mvc.hello.hellohandler 클래스라고 설정하고 있다.
실제로 설정 파일을 사용하는 ControllerUsingFile 컨트롤러 서블릿이 올바르게 동작하는지 살펴보기 위해 간단한 예제를 작성해보자. 먼저 위에서 명시한 HelloHandler 클래스를 아래 코드와 같이 작성한다.
package mvc.hello;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import mvc.command.CommandHandler;
public class HelloHandler implements CommandHandler {
@Override
public String process(HttpServletRequest req, HttpServletResponse res) {
req.setAttribute("hello", "안녕하세요!");
return "/WEB-INF/view/hello.jsp";
}
}
HelloHandler는 뷰 페이지의 URI로 "/view/hello.jsp"를 리턴한다. hello.jsp는 아래 코드와 같이 request의 "hello" 속성값을 호출한다.
<%@ page contentType = "text/html; charset=utf-8" %>
<html>
<head><title>Hello</title></head>
<body>
<%= request.getAttribute("hello") %>
</body>
</html>
이제 직접 URL을 통해서 실행해보자. Hello 명령어를 전달하기 위해서는 다음과 같은 URL을 사용하면 된다.
http://localhost:8080/chap18/controllerUsingFile?cmd=hello
웹 브라우저에서 위 URL을 입력했다면 아래 그림과 같은 결과 화면을 볼 수 잇을 것이다. 이 실행 결과를 보면 설정 파일에 명시한 대로 <명령어, 핸들러 클래스>의 매핑이 처리된 것을 확인할 수 있다.
위에서 살펴본 예제는 cmd 파라미터를 명령어로 사용했다. 명령어 기반의 파라미터는 한 가지 단점이 있는데 그것은 바로 컨트롤러의 URL이 사용자에게 노출된다는 점이다. 앞서 예제에서 hello 명령어를 실행할 때 사용한 URL은 다음과 같았다.
http://localhost:8080/chap18/controllerUsingFile?cmd=hello
명령어를 파라미터를 통해서 전달하기 때문에 사용자는 얼마든지 명령어를 변경해서 컨트롤러에게 요청을 전송할 수 있다. 아무 명령이나 입력해서 실행해보는 사용자도 있을 것이다. 이런 불필요한 공격 아닌 공격을 방지하려면 요청 URI 자체를 명령어로 사용하는 것이 좋다. 즉, 다음과 같이 URL의 일부를 명령어로 사용하는 것이다.
http://localhost:8080/chap18/hello.do
http://localhost:8080/chap18/guestbook/list.do
위와 같은 URL을 사용하면 파라미터로 명령어를 전달할 때 발생하는 사용자의 악의적인 잘못된 명령어 입력을 일차적으로 예방할 수 있다. 또한, URL 자체가 의미가 있으므로 자연스럽게 기능을 설명할 수도 있다.
이처럼 URI를 명령어로 사용하려면 컨트롤러 서블릿의 process() 메서드에서 request.getParameter("cmd") 대신 다음과 같은 코드를 사용하면 된다.
String command = request.getRequestURI();
if(command.indexOf(request.getContextPath()) == 0) {
command = command.substring(request.getContextPath().length());
ControllerUsingFile 컨트롤러 서블릿에서 딱 위의 세 줄만 변경해주면 요청 URI를 명령어로 사용할 수 있게 된다. 위 코드를 보면 요청 URI에서 request.getContextPath() 부분을 제거하는 것을 알 수 있는데, 이는 웹 어플리케이션 내에서의 요청 URI만을 사용하기 위함이다. 예를 들어, chap18 웹 어플리케이션에 대한 요청 URL을 생각해보자.
http://localhost:8080/chap18/guestbook/list.do
여기서 전체 요청 URI는 "/chap18/guestbook/list.do"이지만, 웹 어플리케이션 경로를 제외한 나머지 URI는 "/guestbook/list.do"가 된다. 이렇게, 웹 어플리케이션 경로를 제외한 나머지 URI만을 명령어로 사용하기 위해 request.getContextPath()에 해당하는 부분을 경로에서 제거했다.
ControllerUsingFile 컨트롤러 서블릿을 요청 URI를 명령어로 사용하도록 변경하면 아래 코드와 같아진다. 명령어를 구하는 부분을 제외한 나머지 부분은 완전히 동일한 것을 알 수 있을 것이다.
package mvc.controller;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import mvc.command.CommandHandler;
import mvc.command.NullHandler;
public class ControllerUsingURI extends HttpServlet {
// <커맨드, 핸들러인스턴스> 매핑 정보 저장
private Map<String, CommandHandler> commandHandlerMap =
new HashMap<>();
public void init() throws ServletException {
String configFile = getInitParameter("configFile");
Properties prop = new Properties();
String configFilePath = getServletContext().getRealPath(configFile);
try (FileReader fis = new FileReader(configFilePath)) {
prop.load(fis);
} catch (IOException e) {
throw new ServletException(e);
}
Iterator keyIter = prop.keySet().iterator();
while (keyIter.hasNext()) {
String command = (String) keyIter.next();
String handlerClassName = prop.getProperty(command);
try {
Class<?> handlerClass = Class.forName(handlerClassName);
CommandHandler handlerInstance =
(CommandHandler) handlerClass.newInstance();
commandHandlerMap.put(command, handlerInstance);
} catch (ClassNotFoundException | InstantiationException
| IllegalAccessException e) {
throw new ServletException(e);
}
}
}
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
process(request, response);
}
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
process(request, response);
}
private void process(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
String command = request.getRequestURI();
if(command.indexOf(request.getContextPath()) == 0){
command = command.substring(request.getContextPath().length());
}
CommandHandler handler = commandHandlerMap.get(command);
if (handler == null) {
handler = new NullHandler();
}
String viewPage = null;
try {
viewPage = handler.process(request, response);
} catch (Throwable e) {
throw new ServletException(e);
}
if (viewPage != null) {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPage);
dispatcher.forward(request, response);
}
}
}
특정 확장자(예를 들어, .do 확장자)를 가진 요청을 ControllerUsingURI 컨트롤러 서블릿이 처리하도록 하려면 아래 코드와 같이 web.xml 파일에 <servlet-mapping> 정보를 추가해주어야 한다.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>ControllerUsingURI</servlet-name>
<servlet-class>mvc.controller.ControllerUsingURI</servlet-class>
<init-param>
<param-name>configFile</param-name>
<param-value>/WEB-INF/commandHandlerURI.properties</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>ControllerUsingURI</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
</web-app>
위와 같이 <servlet-mapping> 태그를 web.xml 파일에 추가하면 *.do로 오는 모든 요청은 ControllerUsingURI 컨트롤러 서블릿으로 전달된다.
요청 URL를 명령어로 사용하는 ControllerUsingURI에 알맞은 설정 파일은 아래 코드와 같이 작성할 수 있다.
/hello.do=mvc.hello.HelloHandler
web.xml 파일에 매핑 정보를 추가하고 설정 파일을 알맞게 작성했다면, 다음과 같은 URL을 통해서 요청 URL를 명령어로 사용하는 ControllerUsingFileURI 컨트롤러 서블릿을 사용할 수 있다.
위 URL의 실행 결과는 아래와 같다. 실행 결과를 보면 요청 URI인 "/hello.do"를 명령어로 사용하고 뷰로 hello.jsp가 사용된 것을 알 수 있다.
참고