개요
일단 로그인/로그아웃/회원가입, 글 생성/읽기/수정/삭제 기능을 모두 구현했다. 하지만 이 과정에서 프런트 컨틀롤러가 하는일이 너무 방대해졌다.
- 클라이언트로부터 ServletPath 받기
- 클라이언트의 Request Parameter 및 페이지에 필요한 인스턴스 생성 및 model 객체에 삽입
- ServletPath에 따라 대응하는 페이지 컨트롤러 생성
- 페이지 컨트롤러 실행
- 모델 객체에 있는 데이터 Request 객체로 옮기기
- viewUrl에 해당하는 뷰 인클루딩(또는 리다이랙션)
다음과 같은 과정으로 프런트 컨트롤러는 작업을 처리하게 되는데 이때, 문제가 되는것이 2,3 과정이다.
먼저 2.의 경우 프런트 컨트롤러와 페이지 컨트롤러 사이의 의존성을 높인다. 예를 들어 페이지 컨트롤러가 수정되어 클라이언트가 요청하는 인스턴스의 형식이 달라졌다고 하자. 그러면 프런트 컨트롤러도 수정되어야 한다.
페이지가 필요한/요청하는 데이터를 해당 페이지 컨트롤러에서 정의하고, 실제 생성은 자동화하여 프런트 컨트롤러가 개입하지 않게 하는 것이 객체 지향 관점에서 훨씬 좋다.
3.을 처리하는 코드를 먼저 살펴보자
if (servletPath.equals("/auth/login.do")) {
pageController = new LogInController();
} else if (servletPath.equals("/feed/main.do")) {
pageController = new FeedListController().setFeedDao(feedDao);
} else if (servletPath.equals("/feed/content.do")) {
...
} else if (servletPath.equals("/feed/add.do")) {
...
} else if (servletPath.equals("/feed/edit.do")) {
...
} else if (servletPath.equals("/feed/delete.do")) {
...
} else {
pageController = null;
}
else if 문을 통해 서블릿 경로에 대응하는 페이지 컨트롤러를 생성한다. 하지만 이러한 방식은 페이지가 늘어날 경우, else-if 문이 한 없이 길어지는 문제가 발생하고, 위와 마찬가지로 페이지가 추가/삭제/수정 될때 마다 프런트 컨트롤러의 수정을 요구한다. 이것의 경우, 서블릿 컨텍스트에 서블릿 경로와 대응하는 페이지 컨트롤러를 key-value 형태로 저장하여, 프런트 컨트롤러에서는 로딩만 하도록 하는것이 좋을 것 같다. (추후 아예 자동화 하는 것도 나쁘지 않을듯.)
ServletPath에 따른 페이지 컨트롤러 데이터 ServletContext에 저장하기
ServletPath와 이에 대응하는 페이지 컨트롤러를 key-value의 형태로 서블릿 컨텍스트에 저장해 놓는다.
@WebListener
public class ContextLoaderListener implements ServletContextListener {
private static final Logger LOG = Logger.getGlobal();
@Override
public void contextInitialized(ServletContextEvent event) {
try {
ServletContext sc = event.getServletContext();
InitialContext initialContext = new InitialContext();
DataSource ds = (DataSource) initialContext.lookup(
"java:comp/env/jdbc/crudboard_db"
);
MySqlFeedDao feedDao = new MySqlFeedDao();
MySqlUserDao userDao = new MySqlUserDao();
sc.setAttribute("/auth/login.do", new LogInController().setUserDao(userDao));
sc.setAttribute("/auth/logout.do", new LogOutController());
sc.setAttribute("/auth/join.do", new JoinController().setUserDao(userDao));
sc.setAttribute("/feed/main.do", new FeedListController().setFeedDao(feedDao));
sc.setAttribute("/feed/content.do", new FeedContentController().setFeedDao(feedDao));
sc.setAttribute("/feed/add.do", new FeedAddController().setFeedDao(feedDao));
sc.setAttribute("/feed/edit.do", new FeedEditController().setFeedDao(feedDao));
sc.setAttribute("/feed/delete.do", new FeedDeleteController().setFeedDao(feedDao));
} catch (Throwable e) {
e.printStackTrace();
}
}
@Override
public void contextDestroyed(ServletContextEvent event) {}
}
ServletContextListener 인터페이스는 서블릿이 시작할때, contextInitialized() 메서드를 호출하여 서블릿 컨텍스트를 초기화 할 수 있다.
@WebServlet("*.do")
public class DispatcherServlet extends HttpServlet {
@Override
public void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
ServletContext sc = this.getServletContext();
Controller pageController = (Controller) sc.getAttribute(servletPath);
}
}
프런트 컨트롤러에서 페이지 컨트롤러를 생성하는 부분이다.
인스턴스 생성 자동화
클라이언트가 파리미터를 프런트 컨트롤러에 전달하면 프런트 컨트롤러는 해당 파라미터를 페이지 컨트롤러가 필요한 인스턴스로 생성하여 model 객체에 삽입한다. 이 과정을 자동화해보자.
클라이언트로 부터 RequestParameter를 받아 인스턴스를 생성하는 과정을 정리해보면 다음과 같다.
- 필요한 인스턴스 형식 정의
- 클라이언트로 부터 데이터 받아오기
- 인스턴스의 형식(타입, 이름)에 따라 인스턴스 생성하기
1. 과정의 DataBinding 인터페이스를 새롭게 정의하여 페이지 컨트롤러에서 처리할 것이고, 3. 과정은 ServletRequestDataBinder 클래스를 생성해 작업을 처리할 것이다. 클라이언트와 직접적인 소통이 필요한 2.과정은 기존의 프런트 컨트롤러가 수행한다.
DataBinding 인터페이스
DataBinding 인터페이스는 바인딩할 인스턴스의 타입/이름을 정의하는 인터페이스이다. 그리고 정의한 형식을 Object[] 타입으로 리턴하는 getDataBinders() 메서드를 정의한다.
package crud_board.bind;
public interface DataBinding {
Object[] getDataBinders();
}
페이지 컨트롤러는 DataBinidng 인터페이스의 구현체로써 자신이 필요한 인스턴스의 형식을 Object[] 타입으로 정의하여 getDataBinders() 메서드를 통해 리턴한다.
FeedAddController를 보자. 이 컨트롤러는 새로운 피드를 DB에 삽입하기 위해 Feed 객체를 필요로 한다. 그러므로 다음과 같이 getDataBinders() 메서드를 재정의 한다.
public class FeedAddController implements Controller, DataBinding {
MySqlFeedDao feedDao;
public FeedAddController setFeedDao(MySqlFeedDao feedDao) {
this.feedDao = feedDao; return this;
}
@Override
public Object[] getDataBinders() {
return new Object[] {
"feed", crud_board.vo.Feed.class
};
}
@Override
public String execute(Map<String, Object> model) throws Exception {
...
}
}
필요한 인스턴스의 형식을 정의하는 Object[]의 경우 [ 데이터1 이름, 데이터1 타입, 데이터2 이름, 데이터2 타입, ... ] 형식으로 선언한다.
2개 이상의 인스턴스를 필요로 하는 LoginController의 경우 다음과 같다.
@Override
public Object[] getDataBinders() {
return new Object[] {
"anonymous", String.class,
"id", String.class,
"password", String.class
};
}
다른 페이지 컨트롤러들도 마찬가지로 DataBinding을 구현하여 getDataBinders()를 오버라이딩 하였다.
클라이언트로 부터 데이터 받아오기 & model 객체에 삽입
프런트 컨트롤러는 페이지 컨트롤러가 정의한 dataBinder를 보고, 클라이언트로 부터 데이터를 받아와 ServletRequestDataBinder에게 인스턴스 생성을 요청한다. 그후, model 객체에 해당 인스턴스를 삽입한다.
@WebServlet("*.do")
public class DispatcherServlet extends HttpServlet {
private void prepareRequestData(
HttpServletRequest request, HashMap<String, Object> model, DataBinding dataBinding) throws Exception {
Object[] dataBinders = dataBinding.getDataBinders();
String name = null;
Class<?> type = null;
Object obj = null;
for (int i=0; i<dataBinders.length; i+=2) {
name = (String) dataBinders[i];
type = (Class<?>) dataBinders[i+1];
obj = ServletRequestDataBinder.bind(request, type, name);
model.put(name, obj);
}
}
}
prepareRequestData 메서드는 위에 말한 작업을 처리하는 메서드이다. 작업 처리에 필요한 HtttpServletRequest 객체와 model 객체 DataBinding(페이지 컨트롤러를 프로모션함) 객체를 파라미터로 가진다.
줄 별로 설명해보자면
6 : DataBinding 인터페이스로 부터 데이터 바인더를 받아온다. 바인더의 형식은 [이름,타입,이름,타입,...] 이다.
12-17 : 6에서 리턴받은 바인더로 부터 데이터의 이름과 형식을 받아와 ServletReqestDataBinder에게 인스턴스 생성을 요청한다. 생성 된 인스턴스를 model 객체에 삽입한다.
프런트 컨트롤러(dispatcherServlet)의 service() 메서드에서는 할당 받은 페이지 컨트롤러가 dataBinding을 구현하고 있을 경우, prepareRequestData() 메서드를 호출한다.
if (pageController instanceof DataBinding) {
prepareRequestData(request, model, (DataBinding) pageController);
}
프런트 컨트롤러(DispatcherServlet) 전문이다.
@WebServlet("*.do")
public class DispatcherServlet extends HttpServlet {
@Override
public void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
response.setContentType("text/html; charset=UTF-8");
request.setCharacterEncoding("UTF-8");
String servletPath = request.getServletPath();
HashMap<String, Object> model = new HashMap<>();
try {
HttpSession session = request.getSession();
ServletContext sc = this.getServletContext();
Controller pageController = (Controller) sc.getAttribute(servletPath);
model.put("session", session);
if (pageController instanceof DataBinding) {
prepareRequestData(request, model, (DataBinding) pageController);
}
String viewUrl = "";
if (pageController != null) {
viewUrl = pageController.execute(model);
} else {
viewUrl = "error.jsp";
}
for (String key : model.keySet()) {
request.setAttribute(key, model.get(key));
}
if (viewUrl.startsWith("redirect:")) {
response.sendRedirect(viewUrl.substring(9));
} else {
RequestDispatcher rd = request.getRequestDispatcher(viewUrl);
rd.include(request, response);
}
} catch (Exception e) {
}
}
private void prepareRequestData(
HttpServletRequest request, HashMap<String, Object> model, DataBinding dataBinding) throws Exception {
Object[] dataBinders = dataBinding.getDataBinders();
String name = null;
Class<?> type = null;
Object obj = null;
for (int i=0; i<dataBinders.length; i+=2) {
name = (String) dataBinders[i];
type = (Class<?>) dataBinders[i+1];
obj = ServletRequestDataBinder.bind(request, type, name);
model.put(name, obj);
}
}
}
기존에 있던 else-if문과 model 객체에 삽입할 데이터를 생성하는 부분이 코드에서 사라진 것을 볼 수 있다.
ServletRequestDataBinder 클래스
ServletRequestDataBinder 클래스는 bind() 메서드를 통해 Reqeust 객체 데이터 타입/이름을 이용해 실제 데이터를 생성한다.
bind() 메서드의 실행 과정은 다음과 같다.
- 데이터의 타입이 값 인스턴스(Integer, String ...)인지 직접 정의한 객체인지를 구분한다.
- 값인 경우 타입에 따라 대응하는 인스턴스를 생성한다.
- 객체인 경우, 해당 객체의 세터 메서드 중 필요한 세터 메서드를 호출하여 인스턴스를 생성한다.
다음은 bind() 메서드를 구현한 코드이다.
public static Object bind(ServletRequest request, Class<?> dataType, String dataName) throws Exception {
if (isPrimitiveType(dataType)) {
return createValueObject(dataType, request.getParameter(dataName));
}
Set<String> paramNames = request.getParameterMap().keySet();
Object dataObj = dataType.getDeclaredConstructor().newInstance();
Method setter = null;
for (String paramName : paramNames) {
setter = findSetter(dataType, paramName);
if (setter != null) {
setter.invoke(dataObj, createValueObject(setter.getParameterTypes()[0], request.getParameter(paramName)));
}
}
return dataObj;
}
- 3-5: isPrimitiveType() 메서드는 주어진 데이터의 타입이 일반적인 값 데이터 타입인지를 조사, 그럴 경우 createValueObject() 메서드를 호출해 해당 인스턴스를 생성한다.
- 7-9 : 직접 생성한 객체의 경우, 객체 생성에 필요한 데이터의 이름을 request 객체로 부터 받아온다. 그리고 해당 객체의 인스턴스를 생성한다.
- 11-17 : reqeust 파라미터의 이름과 객체의 세터메서드의 이름을 비교하여 파라미터 이름의 대응하는 세터메서드가 존재할 경우, 해당 세터메서드를 호출해 인스턴스의 필드를 초기화 한다.
이제 위에서 언급한 isPrimitiveType() / createValueObject() / findSetter() 메서드를 하나하나씩 살펴보자.
isPrimitiveType()
private static boolean isPrimitiveType(Class<?> type) {
if (type.getName().equals("int") || type == Integer.class ||
type.getName().equals("long") || type == Long.class ||
type.getName().equals("float") || type == Float.class ||
type.getName().equals("double") || type == Double.class ||
type.getName().equals("boolean") || type == Boolean.class ||
type == String.class || type == Date.class) {
return true;
} else {
return false;
}
}
주언진 데이터타입이 java.lang 클래스에 속하는 기본적인 값 타입인지를 판별한다. 데이터 타입을 파라미터로 받아 해당 타입이 int/double/float/long/boolean/String/Data인 경우 true, 아닌 경우 false를 리턴한다.
createValueObject()
private static Object createValueObject(Class<?> type, String value) {
if (type.getName().equals("int") || type == Integer.class) {
return Integer.valueOf(value);
} else if (type.getName().equals("long") || type == Long.class) {
return Long.valueOf(value);
} else if (type.getName().equals("float") || type == Float.class) {
return Float.valueOf(value);
} else if (type.getName().equals("double") || type == Double.class) {
return Double.valueOf(value);
} else if (type.getName().equals("boolean") || type == Boolean.class) {
return Boolean.valueOf(value);
} else if (type == String.class) {
return String.valueOf(value);
} else {
return java.sql.Date.valueOf(value);
}
}
isPrimitiveType() 메서드를 호출하여 true가 리턴 된 데이터 타입에 대하여 실제 데이터를 생성하는 메서드이다.
파라미터는 데이터 타입(type)과 request 객체로 부터 받은 String 데이터(value)를 갖는다. type에 따라 if-else 문으로 구분하여 대응하는 인스턴스를 생성한다.
findSetter()
private static Method findSetter(Class<?> type, String name) {
Method[] methods = type.getMethods();
String propName = null;
for (Method m : methods) {
if (m.getName().startsWith("set")) {
propName = m.getName().substring(3);
if (propName.toLowerCase().equals(name.toLowerCase())) {
return m;
}
}
}
return null;
}
findSetter() 메서드는 객체의 타입과 데이터 이름을 파라미터로 받아 해당하는 세터 메서드가 있을 경우 메서드를 리턴한다. 이때 데이터 이름과 세터 메서드의 이름 형식에 주목해야 하는데 보통의 경우 세터 메서드는 세팅할 인스턴스의 이름 앞에 set을 붙여 명명한다. 예를 들어 name 인스턴스를 초기화 하는 세터 메서드의 경우 setName 이라고 명명한다. 이를 이용하여 메서드를 구현하였다.
- 5 : 객체의 모든 메서드를 조사한다.
- 6 : 메서드의 이름이 set으로 시작하는지 조사, set으로 시작하지 않은 경우 세터 메서드가 아니므로 통과한다.
- 7 : 세터메서드는 set(인스턴스 이름)으로 명명되었으므로 문자열을 파싱해 세터 메서드가 초기화 하는 인스턴스의 이름을 가져온다.
- 8 : 세터메서드가 초기화 하는 인스턴스의 이름, 파라미터로 주어진 인스턴스의 이름이 같은 경우, 해당 메서드를 리턴한다.
다음은 ServletReuqestDataBinder의 전문이다.
public class ServletRequestDataBinder {
public static Object bind(ServletRequest request, Class<?> dataType, String dataName) throws Exception {
if (isPrimitiveType(dataType)) {
return createValueObject(dataType, request.getParameter(dataName));
}
Set<String> paramNames = request.getParameterMap().keySet();
Object dataObj = dataType.getDeclaredConstructor().newInstance();
Method setter = null;
for (String paramName : paramNames) {
setter = findSetter(dataType, paramName);
if (setter != null) {
setter.invoke(dataObj, createValueObject(setter.getParameterTypes()[0], request.getParameter(paramName)));
}
}
return dataObj;
}
private static boolean isPrimitiveType(Class<?> type) {
if (type.getName().equals("int") || type == Integer.class ||
type.getName().equals("long") || type == Long.class ||
type.getName().equals("float") || type == Float.class ||
type.getName().equals("double") || type == Double.class ||
type.getName().equals("boolean") || type == Boolean.class ||
type == String.class || type == Date.class) {
return true;
} else {
return false;
}
}
private static Object createValueObject(Class<?> type, String value) {
if (type.getName().equals("int") || type == Integer.class) {
return Integer.valueOf(value);
} else if (type.getName().equals("long") || type == Long.class) {
return Long.valueOf(value);
} else if (type.getName().equals("float") || type == Float.class) {
return Float.valueOf(value);
} else if (type.getName().equals("double") || type == Double.class) {
return Double.valueOf(value);
} else if (type.getName().equals("boolean") || type == Boolean.class) {
return Boolean.valueOf(value);
} else if (type == String.class) {
return String.valueOf(value);
} else {
return java.sql.Date.valueOf(value);
}
}
private static Method findSetter(Class<?> type, String name) {
Method[] methods = type.getMethods();
String propName = null;
for (Method m : methods) {
if (m.getName().startsWith("set")) {
propName = m.getName().substring(3);
if (propName.toLowerCase().equals(name.toLowerCase())) {
return m;
}
}
}
return null;
}
}
'프로젝트 이야기 > CRUD 미니 게시판' 카테고리의 다른 글
220322 (5) : 객체 생성 자동화 (0) | 2022.03.22 |
---|---|
220319 (4) : 로그인/회원가입 입력폼 예외처리 (0) | 2022.03.19 |
220310 (2) : 유저 기능 구현 (0) | 2022.03.10 |
220304 (1) : 피드 CRUD(생성/읽기/수정/삭제)기능 구현 (0) | 2022.03.08 |
20220303 (0) : 개요 (0) | 2022.03.03 |