2번째 필수강의 ch3. 15, 16강 요약
DAO(Data Access Object):
데이터에 접근하기 위한 객체로, DB에 저장된 데이터를 읽고, 쓰고, 삭제하고, 변경하는 기능을 수행한다.
- 테이블 하나에 하나의 DAO를 작성한다.
- 영속계층 혹은 Persistence Layer, Data Access Layer라고 부른다.
- Controller는 데이터를 표현하는 Presentation Layer이다.
- 중복 코드, 관심사, 변하는 것과 변하지 않는 것을 분리하기 위해 각기 다른 Layer로 분리되었다.
원래라면 Controller에서도 DB에 접근할 수 있다. 하지만 로그인 컨트롤러와 회원가입 컨트롤러가 있다고 할 때, DB에 접근하는 코드가 중복될 수밖에 없다. 이러한 중복된 코드를 분리하여 DAO를 만들었다고 생각하면 편하다.
중복된 코드와 관심사를 분리하는 과정에서 Controller는 Presentation Layer, DAO는 Persistence Layer(Data Access Layer)가 된다. 이렇게 분리되면 변경에도 유리해져, DB가 바뀐다고 해도 DAO만 손보면 된다.
이 두 레이어 사이에는 비즈니스 로직이 든 Business Layer가 하나 더 들어간다.
어제 test 코드에서 만들었던 selectUser, insertUser, deleteAll 의 메서드들을 모아 UserDAO 클래스를 만든다.
public class UserDao {
@Autowired
DataSource ds;
final int FAIL = 0;
public int deleteUser(String id) {
int rowCnt = FAIL; // insert, delete, update
Connection conn = null;
PreparedStatement pstmt = null;
String sql = "delete from user_info where id= ? ";
try {
conn = ds.getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, id);
} catch (SQLException e) {
e.printStackTrace();
return FAIL;
} finally {
// close()를 호출하다가 예외가 발생할 수 있으므로, try-catch로 감싸야함.
// try { if(pstmt!=null) pstmt.close(); } catch (SQLException e) { e.printStackTrace();}
// try { if(conn!=null) conn.close(); } catch (SQLException e) { e.printStackTrace();}
close(pstmt, conn); // private void close(AutoCloseable... acs) {
}
}
public User selectUser(String id) {
User user = null;
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
String sql = "select * from user_info where id= ? ";
try {
conn = ds.getConnection();
pstmt = conn.prepareStatement(sql); // SQL Injection공격, 성능향상
pstmt.setString(1, id);
rs = pstmt.executeQuery(); // select
if (rs.next()) {
user = new User();
user.setId(rs.getString(1));
user.setPwd(rs.getString(2));
user.setName(rs.getString(3));
user.setEmail(rs.getString(4));
user.setBirth(new Date(rs.getDate(5).getTime()));
user.setSns(rs.getString(6));
user.setReg_date(new Date(rs.getTimestamp(7).getTime()));
}
} catch (SQLException e) {
return null;
} finally {
// close()를 호출하다가 예외가 발생할 수 있으므로, try-catch로 감싸야함.
// close()의 호출순서는 생성된 순서의 역순
// try { if(rs!=null) rs.close(); } catch (SQLException e) { e.printStackTrace();}
// try { if(pstmt!=null) pstmt.close(); } catch (SQLException e) { e.printStackTrace();}
// try { if(conn!=null) conn.close(); } catch (SQLException e) { e.printStackTrace();}
close(rs, pstmt, conn); // private void close(AutoCloseable... acs) {
}
return user;
}
public int insertUser(User user) {
int rowCnt = FAIL;
Connection conn = null;
PreparedStatement pstmt = null;
String sql = "insert into user_info values (?, ?, ?, ?,?,?, now()) ";
try {
conn = ds.getConnection();
pstmt = conn.prepareStatement(sql); // SQL Injection공격, 성능향상
pstmt.setString(1, user.getId());
pstmt.setString(2, user.getPwd());
pstmt.setString(3, user.getName());
pstmt.setString(4, user.getEmail());
pstmt.setDate(5, new java.sql.Date(user.getBirth().getTime()));
pstmt.setString(6, user.getSns());
return pstmt.executeUpdate(); // insert, delete, update;
} catch (SQLException e) {
e.printStackTrace();
return FAIL;
} finally {
close(pstmt, conn); // private void close(AutoCloseable... acs) {
}
}
public int updateUser(User user) {
int rowCnt = FAIL; // insert, delete, update
// Connection conn = null;
// PreparedStatement pstmt = null;
String sql = "update user_info " +
"set pwd = ?, name=?, email=?, birth =?, sns=?, reg_date=? " +
"where id = ? ";
// try-with-resources - since jdk7
try (
Connection conn = ds.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql); // SQL Injection공격, 성능향상
){
pstmt.setString(1, user.getPwd());
pstmt.setString(2, user.getName());
pstmt.setString(3, user.getEmail());
pstmt.setDate(4, new java.sql.Date(user.getBirth().getTime()));
pstmt.setString(5, user.getSns());
pstmt.setTimestamp(6, new java.sql.Timestamp(user.getReg_date().getTime()));
pstmt.setString(7, user.getId());
rowCnt = pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
return FAIL;
}
return rowCnt;
}
public void deleteAll() throws Exception {
Connection conn = ds.getConnection();
String sql = "delete from user_info ";
PreparedStatement pstmt = conn.prepareStatement(sql); // SQL Injection공격, 성능향상
pstmt.executeUpdate(); // insert, delete, update
}
private void close(AutoCloseable... acs) {
for(AutoCloseable ac :acs)
try { if(ac!=null) ac.close(); } catch(Exception e) { e.printStackTrace(); }
}
}
메서드에 예외를 선언해서 예외를 전달하는 것보다는 DAO에 있는 메서드가 예외를 처리하도록 작성되었다. 만약 실행이 실패한다면 영향을 받은 row가 0줄일 것이기 때문에 호출한 쪽에서 작업에 성공했는지 실패했는지 알 수 있다.
사용하고 난 Connection과 PreparedStatement 객체는 close를 해주어야 한다. 그렇지 않으면 메모리가 제대로 반환되지 않아 메모리가 부족해지는 문제가 발생할 수 있다.
close할 때는 열린 순서와 반대로 닫으면 된다. Connection, PreparedStatement, ResultSet 순으로 열리기 때문에 닫을 때는 그 반대로 ResultSet, PreparedStatement, Connection 순으로 close해주어야 한다.
try-with-resources 구문은 try 뒤 괄호 안에 사용할 객체를 선언하고(resources), 중괄호 안에 try를 적용할 코드를 적어주면 된다.
UserDAO에서 Interface를 추출해본다. 우클릭하여 refactor-extract interface하면 이미 만들어진 클래스를 구현체로 만드는 interface를 만들 수 있다.
public인 deleteUser, selectUser, insertUser, updateUser만 인터페이스로 가져간다.
UserDAO는 UserDAOImpl로 이름을 변경해준다.
// 전부 public abstract인 것을 잊지 x
public interface UserDAO {
int deleteUser(String id);
User selectUser(String id);
int insertUser(User user);
int updateUser(User user);
void deleteAll() throws Exception;
}
이렇게 설계하면, DB가 바뀌더라도 구현체만 바꿔주면 된다. DataSource도 마찬가지로 인터페이스로, 자바 빈으로 등록해둔 객체가 주입되는 것이기 때문에 변경에 유리하다.
UserDAO를 사용할 때는 UserDAOImpl 변수를 선언하지 않고, UserDAO를 선언하고 @Autowired를 붙여준다.
UserDAOImpl을 테스트해본다. UserDAOImpl을 우클릭해서 Go to... 를 눌러 테스트를 생성한다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/**/root-context.xml"})
public class UserDAOImplTest extends TestCase {
@Autowired
UserDAO userDAO;
@Test
public void testDeleteUser() {
}
@Test
public void testSelectUser() {
}
@Test
public void testInsertUser() {
}
@Test
public void testUpdateUser() {
}
}
UserDAO를 주입해주려면 UserDAOImpl이 자바 빈으로 등록되어 있어야 한다. 이는 @Component 애너테이션을 붙여주면 되는데, @Repository 애너테이션에 포함되어 있으므로 UserDAOImpl에 @Repository를 붙여주면 된다.
ComponentScan이 root-context.xml에 등록이 되어있지 않아 빨간줄이 쳐진다. servlet-context.xml에 있는 부분을 가져온다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<!-- Root Context: defines shared resources visible to all other web components -->
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"></property>
<property name="url" value="jdbc:mysql://localhost:3306/springbasic?useUnicode=true&characterEncoding=utf8"></property>
<property name="username" value="아이디"></property>
<property name="password" value="비밀번호"></property>
</bean>
<context:component-scan base-package="com.fastcampus.ch3">
<context:exclude-filter type="regex" expression="com.fastcampus.ch3.diCopy*.*"/>
</context:component-scan>
</beans>
근데 나는 이렇게 했더니 예전에 만들어두었던 HomeController의 servletAC가 주입하는 데 오류가 나서 테스트가 계속 fail이 떴다. 그래서 일단 HomeController를 주석처리하고 updateUser를 테스트해보았더니 성공이 떴다.
@Test
public void testUpdateUser() throws Exception{
Calendar calendar = Calendar.getInstance();
calendar.clear();
calendar.set(2024,1,1);
userDAO.deleteAll();
User user = new User(
"aaaa","1111","abc","aaa@aaa.com",
new Date(calendar.getTimeInMillis()),"twitter",
new Date());
int rowCount = userDAO.insertUser(user);
assertTrue(rowCount==1);
user.setSns("instagram");
user.setPwd("12345678");
rowCount = userDAO.updateUser(user);
assertTrue(rowCount==1);
User user2 = userDAO.selectUser(user.getId());
System.out.println("user: "+user);
System.out.println("user2: "+user2);
assertTrue(user.equals(user2));
}
테스트를 통과했다.
이어 다른 기능들도 테스트를 진행해 통과하는 것을 확인하였다.
GitHub에서 LoginController, RegisterController 클래스를 가져온다.
Validation이 들어가기 때문에 pom.xml에 다음과 같은 의존성을 추가해준다.
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
다음으로 UserValidator와 loginForm.jsp, index.jsp, registerForm.jsp, registerInfo.jsp를 만든다.
jsp 파일은 views 안에 생성한다.
resources 아래에 css 폴더를 생성하고, menu.css도 넣어준다.
LoginController에 UserDAO를 주입하고, 맨 밑에 loginCheck를 userDAO에서 selectUser를 호출하는 것으로 바꾼다.
입력받은 id의 user가 있는지 확인하고, 없으면 false를 반환한다. user가 있으면 입력받은 pwd와 저장된 pwd가 일치하는지를 반환한다.
@Autowired
UserDAO userDAO;
...
private boolean loginCheck(String id, String pwd) {
User user = userDAO.selectUser(id);
if (user==null) return false;
return user.getPwd().equals(pwd);
}
역시 UserDAO를 주입해주고, save에 DB에 저장하는 부분을 UserDAO를 사용해 수정한다.
@PostMapping("/add")
public String save(@Valid User user, BindingResult result, Model m) throws Exception {
System.out.println("result="+result);
System.out.println("user="+user);
// User객체를 검증한 결과 에러가 없으면 유저를 저장.
if(!result.hasErrors()) {
// DB에 신규회원 정보를 저장
int rowCount = userDAO.insertUser(user);
if(rowCount!=FAIL) return "registerInfo";
}
return "registerForm";
}
DB를 체크했을 때 에러가 없는 경우에만 user를 저장하고, 성공하면 가입정보를 보여주고, 그 외의 경우에는 다시 registerForm을 반환한다.
http://localhost/ch3/login/login을 입력하면 loginForm.jsp가 브라우저에 로딩된다.
한글이 깨질 때는 1. IntelliJ Settings에서 파일 인코딩을 utf-8로 고치고, 2. File Properties에서도 파일 인코딩을 utf-8로 고쳐준다.
근데 id에 asdf, 비밀번호에 1234를 입력하니 저장되지 않은 정보라고 떴다. DB에 저장해둔 예시 정보가 aaaa, 1111이어서 그랬던 것으로 금방 밝혀졌다(휴).
그대로 입력했더니 로그인은 됐는데 index.jsp가 뜨지 않았다. 로그인되면 redirect로 home에 가게 되어 있는데, home이 등록되어있지 않아 그런 것으로 밝혀졌다.
servlet-context.xml에 view-controller를 등록해준다.
추가로, 이대로는 css 경로를 찾지 못하기 때문에 resources mapping도 수정해준다.
<view-controller path="/" view-name="index"/>
...
<resources mapping="/**" location="/resources/" />
서버를 다시 켜주면 제대로 index 화면이 나온다.
다음으로 회원가입을 진행해보았더니 오류가 반겨주었다.
org.springframework.context.NoSuchMessageException: No message found under code 'invalidLength.user.id' for locale 'ko_KR'.
만일 아이디를 4글자 이하로 입력하면 너무 짧다고 에러 메시지가 떠야하는데, 일치하는 메시지를 찾지 못했다고 뜬다. 이 부분은 resources 아래에 error_message.properties를 만들어주어야 한다.
required=필수 항목입니다.
required.user.pwd=사용자 비밀번호는 필수 항목입니다.
invalidLength.id=아이디의 길이는 {0}~{1}사이어야 합니다.
이 에러 메시지를 servlet-context.xml에서 등록해주어야 한다.
<beans:bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource">
<beans:property name="basenames">
<beans:list>
<beans:value>error_message</beans:value> <!-- /src/main/resources/error_message.properties -->
</beans:list>
</beans:property>
<beans:property name="defaultEncoding" value="UTF-8"/>
</beans:bean>
에러 메시지 한글이 깨진다면, Settings-File Encodings에서 다음 체크박스에 체크를 해준다.
추가로 registerInfo.jsp에서 hobby 부분을 지워주어야 다른 오류가 발생하지 않는다.
오류가 없이 등록이 진행되면 화면에 입력했던 정보들이 뜬다.
또, DB에 가보면 회원정보가 잘 입력된 것을 확인할 수 있다.
지금은 이름을 영어로 입력해서 오류가 발생하지 않았지만, 한글로 입력하는 경우에는 한글 깨짐이 발생한다. 이 경우에는 web.xml에 아래 인코딩 필터를 추가해주어야 한다.
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
하지만 안타깝게도 나는 이 과정을 다 따라해도 에러메시지 오류가 고쳐지질 않았다... 왜지