잊지 않겠습니다.

* 사내 강의용으로 사용한 자료를 Blog에 공유합니다. Spring을 이용한 Web 개발에 대한 전반적인 내용에 대해서 다루고 있습니다.


코드 정리 - 객체간의 중복 코드 정리

지금까지 우리는 books라는 한개의 table에 CRUD action을 진행해왔습니다. 그렇지만, 개발에서는 한개의 Table만에 CRUD를 하는 경우보다, 한 Action에 대하여 여러개의 Table을 CRUD 하게 되는 것이 일반적입니다. 

그래서, 이 두개의 개념을 나누게 되는데요.
하나의 Action이라는 것은 Business Logic이라고 할 수 있고, 한 Table에 대한 CRUD는 DB에 종속적인 작업이라고 할 수 있습니다.
전자를 일반적으로 Service라고 지칭하고, 후자를 DAO (Data Access Object)라고 지칭하는 것이 일반적입니다. 

이는 java에서 개발중에 package로 나누게 되는 것일 일반적입니다. dao package와 service package를 나누것과 같이 이런 식으로 나눠주는 것이 좋습니다.

1개의 Service는 여러개의 DAO를 가지고 있고, DAO를 이용한 BL을 서술하는 것이 일반적입니다. DAO는 최대한 단순하게 Table에 대한 Query들로 구성이 되고, 그 Query를 통해서 결과를 얻어내는 수단으로 주로 사용됩니다.
bookstore에 Business Logic(BL)을 추가하기 전에 book의 상태를 서술할수 있는 property와 현재 책을 빌려간 사용자의 정보를 저장할 수 있는 Column을 두개 추가하고, users table을 추가해서 Dao를 좀더 구성해보도록 하겠습니다. 

먼저 books table에 대한 작업부터 진행하도록 하겠습니다.
책의 상태를 나타낼 수 있는 state property를 추가합니다.
create table books (
  id Integer AUTO_INCREMENT PRIMARY KEY,
  name varchar(255) NOT NULL,
  author varchar(50) NOT NULL,
  publishDate timestamp NOT NULL,
  comment varchar(255),
  status Integer NOT NULL,
  rentUserId Integer
);
ALTER TABLE bookstore.books ADD status Integer NOT NULL;
ALTER TABLE bookstore.books ADD rentUserId Integer;


int type으로 status의 값을 0은 bookstore에 있는 대여 가능, 1은 대여중, 2는 분실 상태로 만들어줍니다. 그런데, 이런 식의 status code는 따로 code table을 만들어주거나 code 상의 enum 값을 이용해서 명확하게 구분하는 것이 훨씬 코드 관리에 용의합니다.  enum의 code는 다음과 같습니다.

public enum BookStatus {
    CanRent(0),
    RentNow(1),
    Missing(2);

    private int value;
    private BookStatus(int value) {
        this.value = value;
    }

    public int intValue() {
        return this.value;
    }

    public static BookStatus valueOf(int value) {
        switch(value) {
        case 0 : return CanRent;
        case 1 : return RentNow;
        case 2 : return Missing;
        default:
            throw new IllegalArgumentException();
        }
    }
}

불분명한 숫자값에 명확한 의미의 enum을 적용함으로서, 코드를 보다더 보기 편하게 만들어줬습니다. 이제 BookStatus를 Book에 추가하도록 하겠습니다.
또한, rentUserId column이 nullable인것에 주목해주세요. 이 부분을 반영하기 위한 Entity는 어떻게 작성해야지 될까요?

마지막으로 기존의 BookApp은 BookDao의 의미가 더 강하기 때문에, package의 이름을 dao로 변경하고, BookApp을 BookDao로 변경하도록 하겠습니다. 전체 코드에 대한 손을 좀 봐야지 됩니다. 한번 고쳐보시길 바랍니다.
코드의 수정이 모두 완료되었으면 테스트 코드를 통해서 코드가 정상적으로 수정이 된 것을 확인해야지 됩니다. 

public class BookDao {
    private ConnectionFactory connectionFactory;

    interface ExecuteUpdateQuery {
        PreparedStatement getPreparedStatement(Connection conn) throws SQLException;
    }

    interface ExecuteSelectQuery {
        PreparedStatement getPreparedStatement(Connection conn) throws SQLException;
        Object parsetResultSet(ResultSet rs) throws SQLException;
    }

    private void execute(ExecuteUpdateQuery query)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Connection conn = this.connectionFactory.getConnection();
        PreparedStatement st = null;
        try {
            st = query.getPreparedStatement(conn);
            st.executeUpdate();
        }
        finally {
            if(st != null) {
                try {
                    st.close();
                }catch(Exception ex) {}
            }
            if(conn != null) {
                try {
                    conn.close();
                }catch(Exception ex) {}
            }
        }
    }

    private Object execute(ExecuteSelectQuery query)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Connection conn = this.connectionFactory.getConnection();
        PreparedStatement st = null;
        ResultSet rs = null;
        try {
            st = query.getPreparedStatement(conn);
            rs = st.executeQuery();
            return query.parsetResultSet(rs);
        }
        finally {
            if(rs != null) {
                try {
                    rs.close();
                } catch(Exception ex) {}
            }
            if(st != null) {
                try {
                    st.close();
                }catch(Exception ex) {}
            }
            if(conn != null) {
                try {
                    conn.close();
                }catch(Exception ex) {}
            }
        }
    }

    private Book convertToBook(ResultSet rs) throws SQLException {
        Book book = new Book();
        book.setId(rs.getInt("id"));
        book.setName(rs.getString("name"));
        book.setAuthor(rs.getString("author"));
        java.util.Date date = new java.util.Date(rs.getTimestamp("publishDate").getTime());
        book.setPublishDate(date);
        book.setComment(rs.getString("comment"));
        book.setStatus(BookStatus.valueOf(rs.getInt("status")));

        return book;
    }

    public void add(final Book book) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        execute(new ExecuteUpdateQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                PreparedStatement st = conn.prepareStatement("insert books(id, name, author, publishDate, comment, status) values(?, ?, ?, ?, ?, ?)");
                st.setInt(1, book.getId());
                st.setString(2, book.getName());
                st.setString(3, book.getAuthor());
                java.sql.Date sqlDate = new java.sql.Date(book.getPublishDate().getTime());
                st.setDate(4, sqlDate);
                st.setString(5, book.getComment());
                st.setInt(6, book.getStatus().intValue());
                return st;
            }
        });
    }

    public Book get(final int id) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        return (Book) execute(new ExecuteSelectQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                PreparedStatement st = conn.prepareStatement("select id, name, author, publishDate, comment from books where id=?");
                st.setInt(1, id);
                return st;
            }

            @Override
            public Object parsetResultSet(ResultSet rs) throws SQLException {
                rs.next();
                Book book = convertToBook(rs);
                return book;
            }
        });
    }

    @SuppressWarnings("unchecked")
    public List<Book> search(final String name) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        return (List<Book>) execute(new ExecuteSelectQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                String query = "select id, name, author, publishDate, comment from books where name like '%" + name +"%'";
                return conn.prepareStatement(query);
            }

            @Override
            public Object parsetResultSet(ResultSet rs) throws SQLException {
                List<Book> books = new ArrayList<>();
                while(rs.next()) {
                    books.add(convertToBook(rs));
                }
                return books;
            }
        });
    }

    public int countAll() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        return (Integer) execute(new ExecuteSelectQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                return conn.prepareStatement("select count(*) from books");
            }

            @Override
            public Object parsetResultSet(ResultSet rs) throws SQLException {
                rs.next();
                return rs.getInt(1);
            }
        });
    }

    public void update(final Book book) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        execute(new ExecuteUpdateQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                PreparedStatement st = conn.prepareStatement("update books set name=?, author=?, publishDate=?, comment=?, status=? where id=?");
                st.setInt(6, book.getId());
                st.setString(1, book.getName());
                st.setString(2, book.getAuthor());
                st.setTimestamp(3, new Timestamp(book.getPublishDate().getTime()));
                st.setString(4, book.getComment());
                st.setInt(5, book.getStatus().intValue());
                return st;
            }
        });
    }

    @SuppressWarnings("unchecked")
    public List<Book> getAll() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        return (List<Book>) execute(new ExecuteSelectQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                return conn.prepareStatement("select id, name, author, publishDate, comment from books");
            }

            @Override
            public Object parsetResultSet(ResultSet rs) throws SQLException {
                List<Book> books = new ArrayList<>();
                while(rs.next()) {
                    books.add(convertToBook(rs));
                }
                return books;
            }
        });
    }

    public void deleteAll() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        execute(new ExecuteUpdateQuery() {
            @Override
            public PreparedStatement getPreparedStatement(Connection conn) throws SQLException {
                return conn.prepareStatement("delete from books");
            }
        });
    }

    public ConnectionFactory getConnectionFactory() {
        return connectionFactory;
    }

    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.connectionFactory = connectionFactory;
    }
}

이제 사용자를 추가해보도록 하겠습니다. users는 다음과 같습니다. 그리고, books와 users 간의 FK를 잡아주도록 합시다.

create table users (
    id Integer AUTO_INCREMENT PRIMARY KEY,
    name varchar(50) NOT NULL,
    password varchar(12) NOT NULL,
    point Integer NOT NULL,
    level Integer NOT NULL  
);
ALTER TABLE bookstore.books ADD CONSTRAINT books_users_FK FOREIGN KEY (rentUserId) REFERENCES bookstore.users(id) ON DELETE SET NULL;

간단히 이름과 비밀번호를 가진 너무나 단순한 키입니다. 역시 마찬가지로 User의 level에 대한 enum값을 만들고, User에 대한 entity를 정의하고, BookDao와 동일한 method들을 모두 만들어주겠습니다.
level에 대한 enum은 NORMAL, READER, MVP로 만들어주세요.

마지막으로 histories Table을 추가합니다. table query는 다음과 같고, users와 books에 FK를 갖습니다.
create table histories (
    id Integer AUTO_INCREMENT PRIMARY KEY,
    userId Integer NOT NULL,
    bookId Integer NOT NULL,
    actionType Integer NOT NULL,
    insertDate timestamp NOT NULL
);
ALTER TABLE bookstore.histories ADD CONSTRAINT history_userFK FOREIGN KEY (userId) REFERENCES bookstore.users(id);
ALTER TABLE bookstore.histories ADD CONSTRAINT history_bookFK FOREIGN KEY (bookId) REFERENCES bookstore.books(id);
 
users와 histories에 대한 모든 dao class를 만들어주세요.  그리고 Histoary의 ActionType은 RENT/RETURN을 각각 0, 1로 잡는 enum으로 구성해주면 전체 table의 구성이 모두 마쳐집니다. 
여기까지 구성을 해보는 것이 이번주 과제입니다. 조금 코드양이 많을것 같네요.  각각의 Dao class의 method 정의는 다음과 같습니다.  이 두개의 Dao 객체를 모두 작성하고, Test code를 작성해주세요.

public class UserDao {
    public User get(int userId) {

    }
    public void deleteAll() {

    }
    public int countAll() {

    }

    public void add(User user) {

    }

    public void update(User user) {

    }

    public List<User> getAll() {

    }
}

public class HistoryDao {
    public void deleteAll() {
        
    }
    
    public void add(History history) {
        
    }
    
    public int countAll() {
        
    }
    
    public List<History> getAll() {
        
    }
    
    public List<History> getByUser(int userId) {
        
    }
    
    public List<History> getByBook(int bookId) {
        
    }
}




Dao를 구현하면 또 다른 중복 코드가 발견되는 것을 알 수 있습니다. 지금까지 구현했던 Template-callback 코드가 계속해서 나타나게 됩니다. 이 부분에 대한 중복 코드를 제거하는 것이 필요합니다. SQL에 대한 직접적인 실행을 하는 객체로 SqlExecutor로 객체를 새로 생성하고, SqlExecutor를 외부에서 DI 할 수 있도록 코드를 수정하도록 합시다. 
SqlExecutor의 코드는 다음과 같습니다. 

public class SqlExecutor {
    private ConnectionFactory connectionFactory;

    public ConnectionFactory getConnectionFactory() {
        return this.connectionFactory;
    }

    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.connectionFactory = connectionFactory;
    }

    public void execute(ExecuteUpdateQuery query)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Connection conn = this.connectionFactory.getConnection();
        PreparedStatement st = null;
        try {
            st = query.getPreparedStatement(conn);
            st.executeUpdate();
        }
        finally {
            if(st != null) {
                try {
                    st.close();
                }catch(Exception ex) {}
            }
            if(conn != null) {
                try {
                    conn.close();
                }catch(Exception ex) {}
            }
        }
    }

    public Object execute(ExecuteSelectQuery query)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Connection conn = this.connectionFactory.getConnection();
        PreparedStatement st = null;
        ResultSet rs = null;
        try {
            st = query.getPreparedStatement(conn);
            rs = st.executeQuery();
            return query.parsetResultSet(rs);
        }
        finally {
            if(rs != null) {
                try {
                    rs.close();
                } catch(Exception ex) {}
            }
            if(st != null) {
                try {
                    st.close();
                }catch(Exception ex) {}
            }
            if(conn != null) {
                try {
                    conn.close();
                }catch(Exception ex) {}
            }
        }
    }
}

지금까지 구현된 모든 Dao를 SqlExecutor를 사용하도록 코드를 작성하고, spring을 사용하도록 DI를 구성한 테스트 코드를 작성해서 검증하도록 합시다.

과제가 나타났습니다!!!! 


<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="connectionFactory" class="com.xyzlast.bookstore02.dao.ConnectionFactory" init-method="init">
        <property name="connectionString" value="jdbc:mysql://localhost/bookstore"/>
        <property name="driverName" value="com.mysql.jdbc.Driver"/>
        <property name="username" value="root"/>
        <property name="password" value="qwer12#$"/>
    </bean>
    <bean id="sqlExecutor" class="com.xyzlast.bookstore02.dao.SqlExecutor">
        <property name="connectionFactory" ref="connectionFactory"/>
    </bean>
    <bean id="bookDao" class="com.xyzlast.bookstore02.dao.BookDao">
        <property name="sqlExecutor" ref="sqlExecutor"/>
    </bean>
    <bean id="userDao" class="com.xyzlast.bookstore02.dao.UserDao">
        <property name="sqlExecutor" ref="sqlExecutor"/>
    </bean>
    <bean id="historyDao" class="com.xyzlast.bookstore02.dao.HistoryDao">
        <property name="sqlExecutor" ref="sqlExecutor"/>
    </bean></beans>



코드 정리 - Exception의 처리

지금까지 보시면 SqlExecutor를 이용한 코드의 간결화 및 spring을 이용한 전체 코드의 최적화를 해온것을 알 수 있습니다. 

마지막으로, 지금 저희 코드에 중복이 되는 코드를 찾아보도록 합시다. 
딱히 문제가 특출나게 보이지는 않습니다. 지금까지 상당한 refactoring을 통해서 변경시킨 코드에 문제가 쉽게 보이면 그것 역시 문제가 될 수 있습니다.

지금 모든 코드에 나타나 있는 Exception이 선언되어 있습니다. DB access 코드에 일괄적으로 들어가 있는 Exception들은 다음과 같습니다.

# InstantiationException : Class.forName 에서 객체의 이름이 아닌, interface의 이름이 들어간 경우에 발생하는 에러.
# IllegalAccessException : Db Connection시, 권한이 없거나 id/password가 틀린 경우에 발생하는 에러
# ClassNotFoundException : Class.forName 을 이용, DB Connection 객체를 생성할 때 객체의 이름이 틀린 경우에 발생하는 에러
# SQLException : SQL query가 잘못된 Exception입니다. 

Java는 2개의 Exception type을 가지고 있습니다. checked exception과 Runtime Exception인데요. checked exception의 경우, 이 exception이 발생하는 경우에는 반드시 exception을 처리해줘야지 됩니다. 또는 상위 method로 throw를 해줘야지 됩니다.
Runtime exception은 상위에서 처리를 안해줘도 되고요. 대표적인 것은 NullPointerException, UnsupportedOperationException, IllegalArgumentException 등이 있습니다.

이 부분은 매우 중요한 개념입니다. java에서의 exception은 사용자가 처리해줘야지 될 것(체크 해야지 될 exception)과 Runtime 시(실행시에) 확인되어야지 될 것들로 나뉘게 됩니다. exception에 대하여 보다더 확실한 처리를 해주길 바란 java의 설계 원칙이지만, 근간에는 비판이 좀 많은 부분이기도 합니다. java의 exception에 대한 정리를 한번 해주시는 것이 필요합니다. 

그럼, 지금 저희가 사용한 코드중에서 getConnection() method를 확인해보겠습니다. 

    public Connection getConnection() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Connection conn = DriverManager.getConnection (this.connectionString, this.username, this.password);
        return conn;
    }


이 method는 이 4개의 exception을 반드시 처리하도록 되어 있습니다. 또는 이 4개의 exception을 사용한 method로 던져줘서 상위 method에서 처리하도록 되어 있는데요. DB 접속과 SQL query가 잘못된 경우에 대한 exception 처리는 과연 할 수 있을까요? 이 Exception은 처리할 수 없는 Exception을 넘겨줘서 되는 것이 아닌가. 라는 생각을 할 수 있습니다. 잘 보면 대부분의 DB 접속시에 나오는 대부분의 Exception은 처리가 불가능한 Exception이라고 할 수 있습니다. 에러의 내용은 도움이 될 수 있지만, 이 에러가 발생했을 때 어떠한 처리를 하지를 못하는 경우가 대다수라는거지요. 그래서 SQL Exception들을 다음과 같이 처리해줘도 괜찮습니다. 

    public Connection getConnection() {     
        Connection conn = null;
        try {
            conn = DriverManager.getConnection (this.connectionString, this.username, this.password);
        } catch (SQLException e) {
            throw new IllegalArgumentException(e);
        }
        return conn;
    }
    
    public void init() {
        try {
            Class.forName(this.driverName).newInstance();
        } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }


모든 Exception들은 초기 값들. id, password, connectionString, driver name 등이 잘못된 것이기 때문에 input 값이 잘못되었다는 의미의 IllegalArgumentException으로 변경해서 던져줬습니다. IllegalArgumentException은 Runtime Exception 이기 때문에 처리를 해줄 필요가 없습니다. 이렇게 Dao 객체들의 method를 다시 처리해주면 좀더 코드가 깔끔해지는 것을 알 수 있습니다. 




Posted by Y2K
,