MVC탄생 배경
기존에 만들었던 서블릿은 모든 기능을 혼자서 처리한다. 서블릿이 데이터를 처리하고 화면을 작성하며 비즈니스 로직도 작성했다. 규모가 커지면 분업해야한다. 회사 규모가 커지면 직무별로 나누듯 개발도 기능별로 나누어야 유지보수하기 편하다. 이렇게 기능을 나눈 것을 MVC라고 한다.
MODEL, VIEW, CONTROLLER라는 기능으로 분리해서 개발을 진행한다.
기존에 서블릿 혼자서 담당하던 일을 세 컴포넌트가 처리한다. 올인원 방식때보다 설계 시간은 오래 걸리지만 유지보수와 확장측면에서 큰 이점이 있다.
- 헷갈리기 쉬운 점
MVC모델을 봤던 사람이라면 MVC의 뜻은 안다. 하지만 서로서로 어떻게 연결이되는지, 또 M,V,C 세개의 컴포넌트인데 CONTROLLER, DAO, DTO, SERVICE 왜 네개의 패키지를 사용하는지 이런 궁금증이 많을 것이기 때문에 이 부분에 중점을 둿다.
MVC란?
- 컨트롤러
하나만 명심하자. 모든 요청은 컨트롤러를 통해서 이동한다. 물론 예외가 있지만 화면에서 클릭해서 페이지를 이동하는 경우 무조건 컨트롤러로 간다고 생각하자. 컨트롤러로 왔다면 이놈이 어디로 보내야할지 결정한다. 오더를 내리는 커맨드센터와 비슷하다.
- 뷰
화면을 띄운다. MVC핵심은 각 컴포넌트가 시킨 일 이외는 하지 않는다. 뷰는 화면만 띄워야 한다. 하지만 JSP를 배우다 보면 뷰 안에 자바코드가 들어갈 때가 있다. JSP이후에 많이 배우는 Mustache같은 것을 사용하면 자바코드 자체를 넣을 수 없기 때문에 모든 로직처리는 백에서 처리하고 화면은 화면만 작성하도록 설계한다. 뷰는 뷰에 충실해진다! (하지만 자바코드를 넣는 편이 설계하기는 쉬워서 jsp부터 배운다.)
- 모델
사용자가 입력한 정보를 토대로 데이터를 다룬다. 모델이 DAO와 SERVICE를 생성한다.
MVC 흐름 (중요!)
- 컨트롤러. 무조건 컨트롤러다. 로그인 화면에서 회원가입 버튼을 누르던, 도서목록에서 도서등록 화면으로 이동하던 무조건 컨트롤러로 보낸다고 생각하자. DB가 필요없는 화면은 a태그로 jsp페이지로 연결해도 되지만 일단은 컨트롤러로 보낸다.
- 컨트롤러가 받을 수 있는 주소로 요청이 왔다면 무조건 Service로 보낸다. 왜 보내나??? 그것은 보내고 생각하자. 일단 받으면 서비스다. 이렇게 설계한 이유는 각 컴포넌트는 자기일만 하기 때문이다. 컴포넌트는 판단하지 않는다. 이 정보는 예외적으로 다른곳으로 보내야하지 않나? 이런 생각은 없다. 무엇이 들어오던 자기할일만 한다.
- 서비스는 데이터베이스가 필요하다면 DAO로 보낸다. 필요없다면 그냥 리턴해서 컨트롤러에 값을 보낸다. 데이터베이스쿼리를 수행하면 값이 리턴될텐데 그것을 받기 위해서 DTO를 생성한다. 데이터베이스에서 유저목록을 조회했다면 아이디, 이메일, 비밀번호, 이름 등의 정보가 select될텐데 이 형식으로 userId, email, password, name 타입 객체를 만든다고 생각하면 된다.
아직은 서비스가 필요없기 때문에 그림에 서비스가 없다. 지금 이 그림이 이해가지 않더라도 넘어가자
뷰의 분리
서버는 어떻게 통신하고 request, response가 무엇인지는 기회가 된다면 작성하겠다.
중요한 것은 흐름이니 상세한 문법은 다루지 않겠다.
JSP는 자바코드를 HTML에서 작성할 수 있도록 한다. 가능한 이유는 JSP를 작성한뒤 실행하면 JSP엔진이 JSP를 서블릿으로 바꿔주기 때문이다. 서블릿에서 주구장창 쓰던 out.println부분을 알아서 만들어준다. 그럼 어떻게 분리할 수 있을까??
필요한 쿼리를 수행하고 결과를 request에 담아서 jsp로 위임하면 된다. jsp는 request를 뜯어서 거기 담긴 데이터를 본인이 지정한 장소에 넣기만 하면 된다.
뷰 코드 제거
@Override
public void doGet(
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
ServletContext sc = this.getServletContext();
Class.forName(sc.getInitParameter("driver"));
conn = DriverManager.getConnection(
sc.getInitParameter("url"),
sc.getInitParameter("username"),
sc.getInitParameter("password"));
stmt = conn.createStatement();
rs = stmt.executeQuery(
"SELECT MNO,MNAME,EMAIL,CRE_DATE" +
" FROM MEMBERS" +
" ORDER BY MNO ASC");
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<html><head><title>회원목록</title></head>");
out.println("<body><h1>회원목록</h1>");
out.println("<p><a href='add'>신규 회원</a></p>");
while(rs.next()) {
out.println(
rs.getInt("MNO") + "," +
"<a href='update?no=" + rs.getInt("MNO") + "'>" +
rs.getString("MNAME") + "</a>," +
rs.getString("EMAIL") + "," +
rs.getDate("CRE_DATE") +
"<a href='delete?no=" + rs.getInt("MNO") +
"'>[삭제]</a><br>");
}
out.println("</body></html>");
} catch (Exception e) {
throw new ServletException(e);
} finally {
try {if (rs != null) rs.close();} catch(Exception e) {}
try {if (stmt != null) stmt.close();} catch(Exception e) {}
try {if (conn != null) conn.close();} catch(Exception e) {}
}
}
여기서 뷰를 담당하는 부분은 어디인가? out.println부분이다. 뷰 부분 코드는 jsp에서 작성한다고 하면 jsp에는 어떤 값을 담아서 보내야할까?
쿼리를 수행하고 rs에 담긴 값이다. 그냥 바로 request.setAttribute(”rs”, rs) 작성하면 작동하지 않을테니 저 값을 클래스에 담아서 보내야 한다. 그 담을 클래스가 dto이다. Member라는 dto를 생성해서 값을 담아보자.
@Override
public void doGet(
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
ServletContext sc = this.getServletContext();
Class.forName(sc.getInitParameter("driver"));
conn = DriverManager.getConnection(
sc.getInitParameter("url"),
sc.getInitParameter("username"),
sc.getInitParameter("password"));
stmt = conn.createStatement();
rs = stmt.executeQuery(
"SELECT MNO,MNAME,EMAIL,CRE_DATE" +
" FROM MEMBERS" +
" ORDER BY MNO ASC");
response.setContentType("text/html; charset=UTF-8");
ArrayList<Member> members = new ArrayList<Member>();
// 데이터베이스에서 회원 정보를 가져와 Member에 담는다.
// 그리고 Member객체를 ArrayList에 추가한다.
while(rs.next()) {
members.add(new Member()
.setNo(rs.getInt("MNO"))
.setName(rs.getString("MNAME"))
.setEmail(rs.getString("EMAIL"))
.setCreatedDate(rs.getDate("CRE_DATE")) );
}
// request에 회원 목록 데이터 보관한다.
request.setAttribute("members", members);
// JSP로 출력을 위임한다.
RequestDispatcher rd = request.getRequestDispatcher(
"/member/MemberList.jsp");
rd.include(request, response);
} catch (Exception e) {
throw new ServletException(e);
} finally {
try {if (rs != null) rs.close();} catch(Exception e) {}
try {if (stmt != null) stmt.close();} catch(Exception e) {}
try {if (conn != null) conn.close();} catch(Exception e) {}
}
}
Include Forward
위 코드를 보면 include라고 선언했다. 이 두개를 이해하기 전에 requestDispatcher를 사용하는 인클루드 포워드 vs sendRedirect를 이해해야한다. 앞서 쿼리를 수행하고 jsp에 위임한다고 했다. 위임하기 위해선 데이터를 담아서 jsp에 보내야하는데 request에 데이터를 담는다. 그리고 jsp를 호출하면서 request와 response를 보내면 jsp는 request를 뜯어본다.
인클루드와 포워드는 request에 데이터를 담아서 위임한다. 그래서 url에 변화는 없다. sendRedirect는 그런거 없다. 그냥 페이지 이동이다. 그래서 url을 바꾼다. sendRedirce가 데이터를 공유하려면 session을 사용해야하니 조금 있다 설명하겠다.
위 케이스의 경우 데이터를 공유해야 하기 때문에 requestDispatcher를 사용했다.
Include는 작동한 뒤 다시 호출한 곳으로 돌아오고 forward는 영영 돌아오지 않는다. 보내놓고 추가작업이 필요한 경우 include, 아닌경우 forward다 대표적인 include의 예로 헤더, 푸터의 분리다. 매번 헤더와 푸터를 작성하기 번거로우니 Hedear.jsp, footer,jsp를 작성해놓고 메인화면에 include로 헤더파일을 불러오면 header로 위임되지 않고 불럿다가 다시 메인화면으로 돌아온다.
뷰 컴포넌트
<%@page import="spms.vo.Member"%>
<%@page import="java.util.ArrayList"%>
<%@ page
language="java"
contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>회원 목록</title>
</head>
<body>
<h1>회원목록</h1>
<p><a href='add'>신규 회원</a></p>
<%
ArrayList<Member> members = (ArrayList<Member>)request.getAttribute("members");
for(Member member : members) {
%>
<%=member.getNo()%>,
<a href='update?no=<%=member.getNo()%>'><%=member.getName()%></a>,
<%=member.getEmail()%>,
<%=member.getCreatedDate()%>
<a href='delete?no=<%=member.getNo()%>'>[삭제]</a><br>
<%} %>
</body>
</html>
JSP에 대한 설명은 생략하겠다. request의 members에 값을 담았으니 그것을 얻는다만 기억하자.
데이터 저장 범위
- Application (=ServletContext) 웹 어플리케이션이 시작하고 끝날때까지 유지된다. 모든 서블릿이 사용할 수 있고 가장 큰 범위다.
- Session: 클라이언트가 요청할 때 생성되고 브라우저를 닫을 때 까지 유지된다. 보통 로그인 로그아웃할 때 사용한다.
- ServletRequest: 요청할 때 생성하고 응답까지 유지된다. 포워딩, 인클루딩할 때 사용한다.
- JSP 한 화면에서만 공유한다.
모든 요청은 setAttribute(키, 값); 값 저장
getAttribute(키); 값 조회
- ServletContext
모든 서블릿에서 공유하는 객체라고 하면 어떻게 사용할 수 있을까? 모든 서블릿에서 사용하는 객체를 선언하고 필요할 때마다 꺼내면 되지 않을까??
데이터베이스 관련 서블릿을 ServletContext에 담고 필요할때 마다 꺼내보자
@SuppressWarnings("serial")
public class AppInitServlet extends HttpServlet {
@Override
public void init(ServletConfig config) throws ServletException {
System.out.println("AppInitServlet 준비…");
super.init(config);
try {
ServletContext sc = this.getServletContext();
Class.forName(sc.getInitParameter("driver"));
Connection conn = DriverManager.getConnection(
sc.getInitParameter("url"),
sc.getInitParameter("username"),
sc.getInitParameter("password"));
sc.setAttribute("conn", conn);
} catch(Throwable e) {
throw new ServletException(e);
}
}
@Override
public void destroy() {
System.out.println("AppInitServlet 마무리...");
super.destroy();
Connection conn =
(Connection)this.getServletContext().getAttribute("conn");
try {
if (conn != null && conn.isClosed() == false) {
conn.close();
}
} catch (Exception e) {}
}
}
클라이언트에서 호출할 필요가 없기 때문에 service는 오버라이딩하지 않아도 된다.
sc.setAttribute("conn", conn); → ServletContext에 conn이라는 이름으로 값을 저장한다.
위와 같이 코드를 작성했다면 web.xml에 서블릿을 선언해줘야한다.
<servlet>
<servlet-name>AppInitServlet</servlet-name>
<servlet-class>spms.servlets.AppInitServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
load on startup를 지정하면 웹어플리케이션이 시작될 때 자동으로 생성하고 안에 숫자는 생성순서다.
이제 MemberListServlet에서 커넥션 부분을 수정하자.
try {
ServletContext sc = this.getServletContext();
conn = (Connection) sc.getAttribute("conn");
stmt = conn.createStatement();
rs = stmt.executeQuery(
"SELECT MNO,MNAME,EMAIL,CRE_DATE" +
" FROM MEMBERS" +
" ORDER BY MNO ASC");
response.setContentType("text/html; charset=UTF-8");
ArrayList<Member> members = new ArrayList<Member>();
// 데이터베이스에서 회원 정보를 가져와 Member에 담는다.
// 그리고 Member객체를 ArrayList에 추가한다.
while(rs.next()) {
members.add(new Member()
.setNo(rs.getInt("MNO"))
.setName(rs.getString("MNAME"))
.setEmail(rs.getString("EMAIL"))
.setCreatedDate(rs.getDate("CRE_DATE")) );
}
// request에 회원 목록 데이터 보관한다.
request.setAttribute("members", members);
// JSP로 출력을 위임한다.
RequestDispatcher rd = request.getRequestDispatcher(
"/member/MemberList.jsp");
rd.include(request, response);
} catch (Exception e) {
e.printStackTrace();
request.setAttribute("error", e);
RequestDispatcher rd = request.getRequestDispatcher("/Error.jsp");
rd.forward(request, response);
기존 코드에서
Class.forName(sc.getInitParameter("driver"));
conn = DriverManager.getConnection(
sc.getInitParameter("url"),
sc.getInitParameter("username"),
sc.getInitParameter("password"));
이 부분이 사라졌다.
세션으로 로그인 유지하는 방법과 EL, JSTL은 간단하니 넘어가겠다.
- DAO 작성이제 서블릿에서 뷰를 담당하는 부분을 분리했으니 데이터베이스와 연동하는 Model부분을 분리해야 한다. 데이터 처리를 전문적으로 하는 객체를 DAO(data access object)라고 부른다.
- 여기서 뷰의 분리작업은 완료했다.직원에대한 삽입, 삭제, 수정은 다양한 부서에서 사용된다. 인사, 회계 등 각 부서마다 데이터베이스에 접근하고 같은 쿼리문을 생성한다면 자원의 낭비다. 그래서 한 테이블에 접근하는 모든 쿼리(DB작업)를 모아놓은 것이 DAO다.DAO도 들리기 전에 무조건 컨트롤러를 거쳐야 한다.
package spms.dao; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.List; import spms.vo.Member; public class MemberDao { Connection connection; public void setConnection(Connection connection) { this.connection = connection; } public List<Member> selectList() throws Exception { Statement stmt = null; ResultSet rs = null; try { stmt = connection.createStatement(); rs = stmt.executeQuery( "SELECT MNO,MNAME,EMAIL,CRE_DATE" + " FROM MEMBERS" + " ORDER BY MNO ASC"); ArrayList<Member> members = new ArrayList<Member>(); while(rs.next()) { members.add(new Member() .setNo(rs.getInt("MNO")) .setName(rs.getString("MNAME")) .setEmail(rs.getString("EMAIL")) .setCreatedDate(rs.getDate("CRE_DATE")) ); } return members; } catch (Exception e) { throw e; } finally { try {if (rs != null) rs.close();} catch(Exception e) {} try {if (stmt != null) stmt.close();} catch(Exception e) {} } } }
위의 serConnecion과 같이 외부에서 Connection객체를 넣는 것을 의존성 주입이라고 한다. connection은 앞서 설정한 initServlet에 설정되어 있고 ServletContext에서 꺼내와야 한다. DAO가 ServletContext까지 접근해서 커넥션을 꺼내오는 것은 권장하지 않기 때문에 외부에서 가져와서 DAO에 삽입했다.
이제 컨트롤러를 수정해보자
DAO를 보면 쿼리결과를 리턴한다. 그렇다면 컨트롤러에서 DAO를 생성하고 selectList()를 받으면 수정이 완료된다.
package spms.servlets;
import java.io.IOException;
import java.sql.Connection;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import spms.dao.MemberDao;
// MemberDao 사용
@WebServlet("/member/list")
public class MemberListServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
public void doGet(
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
try {
ServletContext sc = this.getServletContext();
Connection conn = (Connection) sc.getAttribute("conn");
MemberDao memberDao = new MemberDao();
memberDao.setConnection(conn);
request.setAttribute("members", memberDao.selectList());
response.setContentType("text/html; charset=UTF-8");
RequestDispatcher rd = request.getRequestDispatcher(
"/member/MemberList.jsp");
rd.include(request, response);
} catch (Exception e) {
e.printStackTrace();
request.setAttribute("error", e);
RequestDispatcher rd = request.getRequestDispatcher("/Error.jsp");
rd.forward(request, response);
}
}
}
서블릿 컨테이너는 웹 어플리케이션의 상태를 모니터링 할 수 있도록 주요한 사건마다 알림 기능을 제공한다. 이 알림을 받는 객체를 리스너라고 부른다.
package spms.listeners;
import java.sql.Connection;
import java.sql.DriverManager;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
//import javax.servlet.annotation.WebListener;
import javax.servlet.annotation.WebListener;
import spms.dao.MemberDao;
@WebListener
public class ContextLoaderListener implements ServletContextListener {
Connection conn;
@Override
public void contextInitialized(ServletContextEvent event) {
try {
ServletContext sc = event.getServletContext();
Class.forName(sc.getInitParameter("driver"));
conn = DriverManager.getConnection(
sc.getInitParameter("url"),
sc.getInitParameter("username"),
sc.getInitParameter("password"));
MemberDao memberDao = new MemberDao();
memberDao.setConnection(conn);
sc.setAttribute("memberDao", memberDao);
} catch(Throwable e) {
e.printStackTrace();
}
}
@Override
public void contextDestroyed(ServletContextEvent event) {
try {
conn.close();
} catch (Exception e) {}
}
}
핵심은 웹 어플리케션이 시작될 때 MemberDao객체를 준비하여 ServletCOntext에 보관하는 것이다.
리스너는 @WebListener어노테이션을 붙이거나 web.xml에 선언하면 된다.
기존 서블릿을 고쳐보자
package spms.servlets;
import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import spms.dao.MemberDao;
// ServletContext에 보관된 MemberDao 사용하기
@WebServlet("/member/list")
public class MemberListServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
public void doGet(
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
try {
ServletContext sc = this.getServletContext();
MemberDao memberDao = (MemberDao)sc.getAttribute("memberDao");
request.setAttribute("members", memberDao.selectList());
response.setContentType("text/html; charset=UTF-8");
RequestDispatcher rd = request.getRequestDispatcher(
"/member/MemberList.jsp");
rd.include(request, response);
} catch (Exception e) {
e.printStackTrace();
request.setAttribute("error", e);
RequestDispatcher rd = request.getRequestDispatcher("/Error.jsp");
rd.forward(request, response);
}
}
}
- DB커넥션과 DataSource지금까지 DB커넥션은 하나만 생성해왔다. 이런 커넥션을 여러개 생성하고 관리하는 객체를 DB커넥션풀이라고 한다. 왜 DB커넥션이 필요할까??
- 하나의 커넥션에서 select, insert, delete를 사용한다고 치자. 잘못 수행해서 이전 상태로 되돌려야 하는 경우가 발생하는데 되돌리는 롤백은 커넥션 단위로 수행한다. 이말인 즉 insert가 잘못돼서 롤백을 하면 delete, select로 롤백이된다. 그래서 여러개의 커넥션을 만들고 필요할때마다 쓰고 다 쓰면 반납하는 방식으로 사용한다.
DataSource도 그런 툴이다. DataSource이전에는 리스트로 관리했다.
package spms.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.ArrayList;
public class DBConnectionPool {
String url;
String username;
String password;
ArrayList<Connection> connList = new ArrayList<Connection>();
public DBConnectionPool(String driver, String url,
String username, String password) throws Exception {
this.url = url;
this.username = username;
this.password = password;
Class.forName(driver);
}
public Connection getConnection() throws Exception {
if (connList.size() > 0) {
Connection conn = connList.remove(0);
if (conn.isValid(10)) {
return conn;
}
}
return DriverManager.getConnection(url, username, password);
}
public void returnConnection(Connection conn) throws Exception {
connList.add(conn);
}
public void closeAll() {
for(Connection conn : connList) {
try{conn.close();} catch (Exception e) {}
}
}
}
커넥션 리스트를 만들고 남은 커넥션이 있다면 커넥션을 호출한 곳에 리턴해준다.
- DataSource
데이터 소스는 서버에서 관리하기 때문에 DB커넥션풀보다 편하다. 또한 커넥션 풀도 개발자가 별도로 준비할 필요가 없다.
package spms.listeners;
// Apache DBCP 적용
import java.sql.SQLException;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
import org.apache.commons.dbcp.BasicDataSource;
import spms.dao.MemberDao;
@WebListener
public class ContextLoaderListener implements ServletContextListener {
BasicDataSource ds;
@Override
public void contextInitialized(ServletContextEvent event) {
try {
ServletContext sc = event.getServletContext();
ds = new BasicDataSource();
ds.setDriverClassName(sc.getInitParameter("driver"));
ds.setUrl(sc.getInitParameter("url"));
ds.setUsername(sc.getInitParameter("username"));
ds.setPassword(sc.getInitParameter("password"));
MemberDao memberDao = new MemberDao();
memberDao.setDataSource(ds);
sc.setAttribute("memberDao", memberDao);
} catch(Throwable e) {
e.printStackTrace();
}
}
@Override
public void contextDestroyed(ServletContextEvent event) {
try { if (ds != null) ds.close(); } catch (SQLException e) {}
}
}
코드를 타이핑해가면서 따라하면 에러가 발생할 것이다. 생략한 부분도 있고 DataSource의 경우 context.xml처럼 설정파일에 따로 작업을 해야한다.
핵심은 흐름을 이해하는 것이다. 어차피 스프링을 사용할 것이기 때문에 기술이 왜 태어낫고 어떤식으로 상속받는지를 훑어보면 스프링을 이해가 휠씬 편하다. 자세한 코드나 내용이 보고 싶다면 책을 참고하자.
'IT > 스프링' 카테고리의 다른 글
서블릿과 JDBC (0) | 2022.03.16 |
---|---|
스프링: 느슨한 결합력과 DI (0) | 2021.08.03 |