잊지 않겠습니다.

* 사내 강의용으로 사용한 자료를 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
,

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


지난 장에서는 JDBC를 이용한 db의 접근. 그리고 간단한 table의 CRUD를 하는 방법에 대해서 조금 깊게 들어가봤습니다. 또한 저번 시간의 최대 포인트는 테스트입니다. 테스트를 어떻게 작성을 하는지에 대한 논의와 테스트 코드를 직접 사용해보는 시간을 가져봤습니다. 이번 시간에는 드디어 Spring을 이용한 코드에 대해서 논의해보도록 하겠습니다.기존 코드의 가장 큰 문제는 무엇인가요?

1.  DB connection이 변경되는 경우, compile을 다시 해줘야지 된다.
2.  DB connection의 open/close가 모든 method에서 반복된다.

크게 보면 이 두가지의 문제가 나오게 됩니다. 

Connection은 외부에서 설정할 수 있는 영역입니다. 특히 이런 부분은 config file로 관리가 되는 것이 일반적이고, Enterprise 구성환경에서는 JNDI를 이용한 DB Connection이 제공되는 경우도 많습니다. 따라서, 외부에서 설정이 가능한 것이라고 생각해도 좋습니다. 또한, BookApp은 books table에 CRUD를 하는 것을 목적으로 하는 객체입니다. 객체에 대한 가장 큰 원칙인 단일 책임의 원칙에 의해서, BookApp에서 Connection까지 관리가 되는 것은 영역을 넘어가게 됩니다. 또한, books 이외의 table이 존재할 때, Connection에 대한 코드는 중복될 수 밖에 없는 코드가 됩니다. 따라서 Connection을 제공하는 객체로 따로 분리를 해줍시다. 

Connection을 관리, 생성하는 객체이기 때문에 ConnectionFactory라고 명명하고, 객체를 구성합니다. 객체의 코드는 다음과 같습니다.

public class ConnectionFactory {
     private String connectionString;
     private String driverName;
     private String username;
     private String password;
    
     public Connection getConnection() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          Class.forName(this.driverName).newInstance();
          Connection conn = DriverManager.getConnection (this.connectionString, this.username, this.password);
          return conn;
     }
     public String getConnectionString() {
          return connectionString;
     }
     public void setConnectionString(String connectionString) {
          this.connectionString = connectionString;
     }
     public String getDriverName() {
          return driverName;
     }
     public void setDriverName(String driverName) {
          this.driverName = driverName;
     }
     public String getUsername() {
          return username;
     }
     public void setUsername(String username) {
          this.username = username;
     }
     public String getPassword() {
          return password;
     }
     public void setPassword(String password) {
          this.password = password;
     }
}

그리고, ConnectionFactory를 이용한 코드로 TestCode를 다시 구성해보도록 하겟습니다. 

public class BookAppTest {
     private BookApp bookApp = new BookApp();
     private ConnectionFactory connectionFactory = new ConnectionFactory("com.mysql.jdbc.Driver", "jdbc:mysql://localhost/bookstore", "root", "qwer12#$");
    
     private List<Book> getBooks() {
          Book book1 = new Book();
          book1.setId(1);
          book1.setName("book name01");
          book1.setAuthor("autor name 01");
          book1.setComment("comment01");
          book1.setPublishDate(new Date());
         
          Book book2 = new Book();
          book2.setId(2);
          book2.setName("book name02");
          book2.setAuthor("autor name 02");
          book2.setComment("comment02");
          book2.setPublishDate(new Date());
          Book book3 = new Book();
          book3.setId(3);
          book3.setName("book name03");
          book3.setAuthor("autor name 03");
          book3.setComment("comment03");
          book3.setPublishDate(new Date());
         
          List<Book> books = Arrays.asList(book1, book2, book3);
          return books;
     }
    
     private void compareBook(Book book) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          Book dbBook = bookApp.get(book.getId());
          assertThat(dbBook.getName(), is(book.getName()));
          assertThat(dbBook.getAuthor(), is(book.getAuthor()));
          assertThat(dbBook.getComment(), is(book.getComment()));
          assertThat(dbBook.getPublishDate().toString(), is(book.getPublishDate().toString()));
     }
    
     @Before
     public void setUp() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          bookApp.setConnectionFactory(connectionFactory);
          bookApp.deleteAll();
          assertThat(bookApp.countAll(), is(0));
     }
    
     @Test
     public void addAndCount() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          List<Book> books = getBooks();
          int count = 0;
          for(Book book : books) {
               bookApp.add(book);
               count++;
               assertThat(bookApp.countAll(), is(count));
          }
     }
    
     @Test
     public void update() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          List<Book> books = getBooks();
          int count = 0;
          for(Book book : books) {
               bookApp.add(book);
               count++;
               assertThat(bookApp.countAll(), is(count));
              
               book.setName("changed name");
               book.setPublishDate(new Date());
               book.setAuthor("changed author");
               bookApp.update(book);
              
               compareBook(book);
          }
     }
    
     @Test
     public void getAll() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          List<Book> books = getBooks();
          int count = 0;
          for(Book book : books) {
               bookApp.add(book);
               count++;
               assertThat(bookApp.countAll(), is(count));
          }
         
          List<Book> books2 = bookApp.getAll();
          assertThat(books2.size(), is(books.size()));
     }
    
     @Test
     public void search() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          List<Book> books = getBooks();
          int count = 0;
          for(Book book : books) {
               bookApp.add(book);
               count++;
               assertThat(bookApp.countAll(), is(count));
          }
         
          List<Book> searchedBooks = bookApp.search("01");
          assertThat(searchedBooks.size(), is(1));
         
          searchedBooks = bookApp.search("02");
          assertThat(searchedBooks.size(), is(1));
         
          searchedBooks = bookApp.search("03");
          assertThat(searchedBooks.size(), is(1));
         
          searchedBooks = bookApp.search("name");
          assertThat(searchedBooks.size(), is(3));
     }
}

지금까지 구성된 BookApp을 지금 사용하는 객체는 test 객체만이 유일한 client입니다. 사용하는 객체(client)에서 자신이 사용할 객체의 구성을 결정하는 행위를 IoC(inverse of control)이라고 합니다. 

여기서, Spring의 가장 주요한 기능을 사용할 때가 왔습니다. 

Spring은 IoC를 지원하는 경량 container입니다. 

그런데, IoC를 (어떻게) 지원하는 지에 대한 설명을 한다면 어려운 말로 DI를 통해서 지원한다고 할 수 있습니다. DI란 Dependency Injection의 약자입니다. 사용할 객체의 Dependency를 inject 한다는 뜻입니다. 여기서 말이 좀더 어려워서 예시를 이용해서 바꿔주도록 하겠습니다. 

BookApp은 ConnectionFactory에 의존한다.
BookAppTest는 BookApp에 의존한다.
BookAppTest는 BookApp의 ConnectionFactory를 변경시켜서 사용한다.

최종적으로, BookAppTest는 BookApp을 사용하지만, BookApp이 종속되어 있는 ConnectionFactory를 변경시켜서 사용하게 됩니다. 이때, 의존되어 있는 ConnectionFactory를 변경하는 행위를 DI(dependency Injection)이라고 합니다. 따라서, 위 코드를 좀 유식한 말로 풀어서 한다면, BookApp을 DI를 통해 사용하는 코드 라고 할 수 있습니다. 이를 그림으로 도식화 해보면 다음과 같습니다. 


지금 이야기드린 IoC, DI는 Spring에서 굉장히 중요한 개념입니다. Spring은 light DI container라고 할 정도로 Spring의 핵심 기능중 하나입니다. 



지금까지 작성된 코드를 보면 처음과는 다른 특징을 가지고 있습니다.
1. 테스트를 통해서 검증이 가능합니다.
2. connectionString 및 jdbc Driver를 사용하는 code내에서 찾아서 사용합니다.
3.중복 코드가 존재하지 않습니다.

위의 특징은 잘 짜진 코드라면 당연히 가져야지 되는 특징입니다.


Spring을 이용한 IoC/DI

spring을 이용하면 지금까지 구성되었던 코드를 보다 더 깔끔하게 만들어줄 수 있습니다. 먼저 spring에 대한 dependency를 추가시켜줍니다.

    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-core</artifactId>
      <version>3.2.0.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-beans</artifactId>
      <version>3.2.0.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-test</artifactId>
      <version>3.2.0.RELEASE</version>
      <scope>test</scope>
    </dependency>

spring-test는 scope를 이용해서, test code에서만 사용하도록 지정한것을 제외하고는 mysql dependency를 추가하는 방법과 동일하게 설정해줍니다.
applicationContext.xml 파일을 src/test/resource에 추가하고 다음과 같이 적어줍니다.
<?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.bookstore01.domain.ConnectionFactory">
        <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="bookApp" class="com.xyzlast.bookstore01.domain.BookApp">
        <property name="connectionFactory" ref="connectionFactory"/>
    </bean></beans>

solution의 directory 구조는 다음과 같습니다. 





테스트 코드를 다음과 같이 수정합니다.


    @Before
    public void setUp() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        ApplicationContext context = new GenericXmlApplicationContext("/appContext.xml");
        bookApp = (BookApp) context.getBean("bookApp");
        System.out.println(bookApp);
        bookApp.deleteAll();
        assertThat(bookApp.countAll(), is(0));
    }


테스트 코드가 모두 통과됨을 알 수 있습니다. 어떻게 해서 이렇게 된 것인지 서술해보도록 하겠습니다.
applicationContext.xml 파일은 사용할 객체에 대한 정의 파일입니다. spring에서는 ApplicationContext로 불리우는 객체에 대한 Hashtable 이라고 생각하시면 됩니다.
appContext에 정의된 ConnectionFactory 객체와 BookApp 이라는 객체에 대한 관계를 봐보시면 좀 더 생각이 쉬울 수 있습니다. 먼저 connectionFactory 라는 이름으로 ConnectionFactory를 정의합니다. 저희가 property로 뽑은 connectionString, driverName, username, password에 대한 값들을 모두 설정하는 것을 볼 수 있습니다. 그리고, bookApp이라는 이름으로 설정된 BookApp 객체를 보시면 좀더 재미잇는 코드를 볼 수 있습니다. ref 라는 키워드로 값을 설정하고 있는데. 이는 먼저 설정된 connectionFactory 객체를 bookApp에 주입(Inject) 시키고 있는 것을 알 수 있습니다.
그리고, 테스트 코드에 대해서 알아보도록 하겠습니다. 테스트 코드에서는 먼저 GenericXmlApplicationContext 객체를 이용해서 applicationContext를 전체 로드합니다. 로드된 xml을 기초로 객체들을 생성하고, 그 객체를 Hashtable 형태로 저장하는 일을 합니다.
Hashtable 형태로 저장된 객체들은 각각의 id를 통해서 로드가 가능하며, bookApp = (BookApp) context.getBean("bookApp"); 코드를 통해서 BookApp을 로드할 수 있습니다. 이렇게 spring을 통해서 객체를 사용하는 것이 어떤 장점을 갖게 될까요?

Spring을 사용한 객체의 사용의 장점은 크게 3가지로 볼 수 있습니다.

1. 정해진 규칙에 따른 객체의 선언
2. xml로 정의된 객체의 dependency 파악이 가능
3. 테스트의 편의성

먼저 정해진 규칙에 따른 객체의 선언은 팀단위의 개발자들에게 일정한 개발 패턴을 만들어줍니다. 정해진 개발 패턴은 정형화된 코드를 만들고, 서로간에 코드의 공유가 원활하게 할 수 있습니다. 그리고, xml을 통한 객체의 의존성 관리는 객체들이 어떠한 구조를 가지고 있는지를 파악하는데 도움을 줍니다. 마지막으로 테스트의 편의성을 들 수 있습니다. spring은 테스트 코드를 작성하는데, 지금까지 보던 코드보다 더욱 깔끔하고 쉬운 테스트 패턴을 제공하고 있습니다. Spring을 사용한 테스트 코드를 다시 한번 알아보도록 하겠습니다. 

지금까지 작성된 코드를 기반으로, Spring을 이용한 테스트 코드는 다음과 같이 구성될 수 있습니다. 

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("/applicationContext.xml")
public class BookAppTest {
    @Autowired
    private ApplicationContext context;
    private BookApp bookApp;

    @Before
    public void setUp() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        bookApp = (BookApp) context.getBean("bookApp");
        System.out.println(bookApp);
        bookApp.deleteAll();
        assertThat(bookApp.countAll(), is(0));
    }

Test class에 @RunWith와 @ContextConfiguration annotation이 붙어 있는 것을 알 수 있습니다. @RunWith는 Spring을 이용한 JUnit4 test class임을 명시하는 선언입니다. 중요한것은 @ContextConfiguration인데, 이는 방금 작성한 applicationContext.xml를 지정하는 영역입니다. @ContextConfiguration이 설정된 경우, Test class가 로드 되면서, xml 파일안에 위치한 객체를 생성해서 spring application context에 저장하고 있습니다. 4 line에 위치한 @Autowired는 spring application context 중에 type이 동일한 객체를 자동으로 할당하는 annotation입니다. spring test는 1개의 applicationContext를 로드하기 때문에, 그 객체를 context 변수에 자동으로 할당하게 됩니다. 자신이 구성한 applicationContext.xml은 반드시 이러한 과정을 거쳐서 테스트를 통과시켜야지 됩니다. 


Spring에서의 객체의 생명 주기


spring에서의 객체의 생명 주기는 기본적으로 한번 사용된 객체를 재사용합니다. 테스트 코드를 통해서 이를 확인해보도록 하겠습니다.
구성된 코드에 System.out.println 을 이용해서 bookApp 객체의 instance id를 확인해보도록 하겠습니다. 

     @Before
     public void setUp() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          //context = new GenericXmlApplicationContext("/appContext.xml"); @ContextConfiguration에 의하여 필요없는 코드가 되었습니다.
          bookApp = (BookApp) context.getBean("bookApp");
          System.out.println(bookApp);
          bookApp.deleteAll();
          assertThat(bookApp.countAll(), is(0));
     }

테스트 코드의 console 창의 결과는 다음과 같습니다.

INFO: Pre-instantiating singletons in org.springframework.beans.factory.support.DefaultListableBeanFactory@61305d5b: defining beans [connectionFactory,bookApp,org.springframework.context.annotation.internalConfigurationAnnotationProcessor,org.springframework.context.annotation.internalAutowiredAnnotationProcessor,org.springframework.context.annotation.internalRequiredAnnotationProcessor,org.springframework.context.annotation.internalCommonAnnotationProcessor,org.springframework.context.annotation.ConfigurationClassPostProcessor.importAwareProcessor]; root of factory hierarchy
com.xyzlast.bookstore01.domain.BookApp@240615ef
com.xyzlast.bookstore01.domain.BookApp@240615ef
com.xyzlast.bookstore01.domain.BookApp@240615ef
com.xyzlast.bookstore01.domain.BookApp@240615ef
2월 18, 2013 2:57:58 오후 org.springframework.context.support.AbstractApplicationContext doClose

보시면 BookApp의 모든 객체 Id가 동일함을 알 수 있습니다. ApplictionContext에 저장된 객체를 얻을 때, 새로 생성하는 것이 아닌 기존의 객체를 계속해서 사용하는 것을 알 수 있습니다.
왜 모든 객체들을 기본적으로 재사용하게 될까요? 기본적으로 spring은 enterprise development framework입니다. enterprise급의 대규모 시스템에서는 객체의 생성/삭제가 많은 부담을 주게 됩니다. 이에 대한 해결 방법으로 spring은 application context가 로드 될 때, 기본 객체들을 모두 생성, 로드 하는 것을 기본으로 하고 있습니다. 물론, 다른 방법역시 가능합니다.

Spring에서의 객체의 생명주기는 scope로 불리고, 다음 4가지로 관리가 가능합니다.

1.singleton
2.prototype
3.session
4.request

singleton은 default 값입니다. scope를 따로 설정하지 않으면 모두 singleton으로 동작합니다. 이는 객체를 static object와 동일하게 사용하게 됩니다.
prototype은 일반적으로 저희가 사용하던 객체의 생성방법과 동일합니다. new 를 통해서 객체를 생성하고, property 값을 모두 설정시킨 후, 그 객체를 넘겨주게 됩니다.
session과 request는 web programming에서 사용되는 scope입니다. 새로운 session이 생성될 때, 새로운 request가 생성이 될때 사용될 수 있는 scope 입니다.
크게는 singleton과 prototype을 이용하면 대부분의 객체 생명주기 관리는 가능하게 되며 그 차이를 한번 알아보도록 하겠습니다. scope를 prototype으로 선언해보도록 하겠습니다.

    <bean id="bookApp" class="com.xyzlast.bookstore01.domain.BookApp" scope="prototype">
        <property name="connectionFactory" ref="connectionFactory"/>
    </bean>

그리고, 테스트코드를 수행해보도록 하겠습니다.

2월 18, 2013 2:45:30 오후 org.springframework.beans.factory.support.DefaultListableBeanFactory preInstantiateSingletons
INFO: Pre-instantiating singletons in org.springframework.beans.factory.support.DefaultListableBeanFactory@61305d5b: defining beans [connectionFactory,bookApp,org.springframework.context.annotation.internalConfigurationAnnotationProcessor,org.springframework.context.annotation.internalAutowiredAnnotationProcessor,org.springframework.context.annotation.internalRequiredAnnotationProcessor,org.springframework.context.annotation.internalCommonAnnotationProcessor,org.springframework.context.annotation.ConfigurationClassPostProcessor.importAwareProcessor]; root of factory hierarchy
com.xyzlast.bookstore01.domain.BookApp@240615ef
com.xyzlast.bookstore01.domain.BookApp@3622102f
com.xyzlast.bookstore01.domain.BookApp@78f14616
com.xyzlast.bookstore01.domain.BookApp@c4e6fe5
2월 18, 2013 2:45:31 오후 org.springframework.context.support.AbstractApplicationContext doClose

각각 4개의 BookApp bean이 생성됨을 알 수 있습니다. spring 설정 만으로 다음과 같은 코드가 만들어지는 것과 동일한 효과를 가지고 오게 되는 것입니다.

BookApp bookApp = new BookApp();
bookApp.setConnectionFactory(new ConnectionFactory);

이 부분을 보면 Factory Pattern에서의 Factory와 동일한 기능을 가지게 되는 것을 알 수 있는데요. ApplicationContext는 bean의 Map과 Factory를 지원한다. 라고 할 수 있습니다.
spring을 사용하게 되면, 객체들을 new로 새롭게 할당하는 일들이 얼마 없습니다. 모든 객체 bean들은 spring을 통해서 관리가 되고, bean들간의 데이터 교환을 위한 POJO(Plain Old Java Object)들만 new로 생성되어서 데이터 교환이 되는 것이 일반적인 패턴입니다. POJO에 대해서는 좀더 나중에 알아보도록 하겠습니다.


Spring에서의 객체 주입과 이용에 대한 심화 학습

먼저 이야기한 내용에서 bean의 Map을 제공한다는 이야기를 했습니다. Spring은 ApplicationContext라는 bean Map / Factory를 가지고 있고, ApplicationContext를 통해서 객체를 얻어내거나 생성하게 됩니다. 이때, 객체의 초기화 방법은 다음 두가지로 나눌 수 있습니다. 
1. property를 이용한 주입
2. 생성자를 이용한 주입

property를 이용한 주입은 기존 connectionFactory의 xml 선언을 보면 쉽게 알 수 있습니다.

<bean id="connectionFactory" class="com.xyzlast.bookstore01.domain.ConnectionFactory">
    <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>


생성자를 이용한 주입은 constructor-arg를 이용해서 선언 가능합니다.

<bean id="connectionFactory" class="com.xyzlast.bookstore01.domain.ConnectionFactory">
    <constructor-arg index="0" value="jdbc:mysql://localhost/bookstore"/>
    <constructor-arg index="1" value="com.mysql.jdbc.Driver"/>
    <constructor-arg index="2" value="root"/>
    <constructor-arg index="3" value="qwer12#$"/>
</bean>   


어떤 방법이 좋은지에 대해서는 찬/반이 나뉘고 있습니다. 장점과 단점은 다음과 같습니다.
방법장점단점
Property를 이용한 방법1. 설정값에 대한 설명을 Property를 통해 명확히 알 수 있다.
2. 객체가 변경되었을 때, 확장이 용의하다.
1. 사용되는 Bean의 내용을 정확히 알지 못하면, Property 값을 설정하는 것을 빼먹을 수 있다.
생성자를 이용한 방법1. 필요한 값을 모두 설정하는 것이 가능하다.2. 값을 index와 같이, 순서를 이용하기 때문에 어떤 값을 설정하는지 파악하기 힘들다.

둘다 좋은 방법이지만, spring을 이용하는 대부분의 library 들은 property를 이용한 주입 을 주로 하고 있습니다. 이건 개발을 하는 팀에서 한가지로 정해서 가는 것이 좋습니다.

객체를 사용하다보면, 객체를 초기화 시켜야지 되는 경우가 자주 생깁니다. 지금 코드에서는 ConnectionFactory의 Class.forName().newInstace()의 경우에는 한번만 실행되어도 코드의 동작에는 아무런 문제가 없기 때문에, 생성 후, 한번만 실행이 되어도 아무런 문제가 없습니다. 이러한 초기화 method를 실행시키는 xml선언은 다음과 같습니다. 

    <bean id="connectionFactory" class="com.xyzlast.bookstore01.domain.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>


init-method가 포함된 객체의 생성 process는 다음과 같습니다. (singleton scope의 경우)

1. 객체의 생성
2. property 값들의 설정
3. init-method 의 실행

init-method가 property값들이 모두 설정 된 후에 실행됨을 확인해주셔야지 됩니다. 


Summary

이번 장에서는 Spring을 이용한 객체의 관리를 위주로 Simple Application을 작성해봤습니다. 다음 개념들은 반드시 다시 정리해보시는 것이 좋습니다.

1. ApplicationContext : Spring에서 제공하는 bean의 Map/ObjectFactory
2. property, constructor, init-method를 이용한 객체 초기화 방법
3. IoC, DI


Posted by Y2K
,

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


이 장의 제목은 Toby의 Spring Framework에서 붙인 이름을 그대로 표절해봤습니다. 매우 큰 문제를 가진 간단한 프로그램이 뛰어난 확장성과 처음의 너저분한 코드에서 점차 깔끔하게 구성 되어가는 코드로 점차 변경되어가는 것을 볼 수 있을겁니다.
먼저, 간단한 application입니다. bookStore라고 하나의 Project를 만들고, books 라는 table에 대한 CRUD와 count를 하는 application을 간단히 작성해보도록 하겠습니다.
매우 빠르게 코딩을 하면 다음과 같은 매우 문제가 심한 코드를 발견할 수 있을것입니다.
먼저, eclipse 에서 maven을 이용한 project를 생성합니다. simple application은 책방으로, 책을 관리하는 application을 작성하도록 하겠습니다.
작성된 project의 폴더 구조를 보면 pom.xml 파일이 존재합니다. pom.xml의 dependency 항목에 다음 항목을 추가합니다. 다음 항목이 추가 되면 우리 project는 mysql db connection Driver를 사용하게 되며, 버젼은 5.1.22라는 것을 명시하게 됩니다. 이와 같이 pom 파일은 작성된 project가 어떤 library들에 종속성을 갖게 되는지 확인할 수 있는 결과를 담고 있습니다.


    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.22</version>
    </dependency>


또한, 사용할 junit의 version 정보를 4.11로 수정해줍니다.

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
추가후, pom.xml 파일을 저장하고 dependency가 어떻게 변화되었는지 확인해봅니다. mysql에 관련된 jar가 추가된 것을 알 수 있습니다. 이와 같이 maven을 이용하면, Project에 대한 종속성과 library들을 추가 설정 없이, pom.xml만으로 관리가 가능합니다.
그리고, 본격적인 코딩을 해봅시다.
책은 이름, 저자, 발행일, comment와 db에 저장되기 때문에 integer type의 id를 갖습니다. 이에 대한 java bean object는 다음과 같이 생성될 수 있습니다.


package com.xyzlast.mybook01.entity;
import java.util.Date;
public class Book {
     @Override
     public String toString() {
          return "Book [id=" + id + ", name=" + name + ", author=" + author
                    + ", publishDate=" + publishDate + ", comment=" + comment + "]";
     }
     private int id;
     private String name;
     private String author;
     private Date publishDate;
     private String comment;
    
     public int getId() {
          return id;
     }
     public void setId(int id) {
          this.id = id;
     }
     public String getName() {
          return name;
     }
     public void setName(String name) {
          this.name = name;
     }
     public String getAuthor() {
          return author;
     }
     public void setAuthor(String author) {
          this.author = author;
     }
     public Date getPublishDate() {
          return publishDate;
     }
     public void setPublishDate(Date publishDate) {
          this.publishDate = publishDate;
     }
     public String getComment() {
          return comment;
     }
     public void setComment(String comment) {
          this.comment = comment;
     }
}



그리고, 이 데이터를 저장하기 위한 db를 만들어줍니다. command 창에 들어가서 mysql monitor에 들어가 다음 query를 실행합니다.
create datbase bookstore;
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)
);

자, book에 대한 CRUD를 한다면, 다음 method들이 필요하게 될 것입니다.
void add(Book book);
Book get(int id);
void update(Book book);
void delete(Book book);

여기에 부가적인 코드를 추가하기 위해서 다음 method들을 추가하도록 합니다.
int countAll();
void deleteAll();
List<Book> getAll();
List<Book> search(String name);


이 부분에 대한 코딩을 빠르게 해주시길 바랍니다. package name은 자신의 package name에 domain을 붙여서 넣어주시면 됩니다.
public class BookApp {
     public void add(Book book) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          String url = "jdbc:mysql://localhost/bookstore";
          Class.forName("com.mysql.jdbc.Driver").newInstance();
          Connection conn = DriverManager.getConnection (url, "root", "qwer12#$");
         
          PreparedStatement st = conn.prepareStatement("insert books(name, author, publishDate, comment) values(?, ?, ?, ?)");
          st.setString(1, book.getName());
          st.setString(2, book.getAuthor());
          java.sql.Date sqlDate = new java.sql.Date(book.getPublishDate().getTime());
          st.setDate(3, sqlDate);
          st.setString(4, book.getComment());
          st.execute();
         
          st.close();
          conn.close();
     }
    
     public Book get(int id) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          String url = "jdbc:mysql://localhost/bookstore";
          Class.forName("com.mysql.jdbc.Driver").newInstance();
          Connection conn = DriverManager.getConnection (url, "root", "qwer12#$");
         
          PreparedStatement st = conn.prepareStatement("select id, name, author, publishDate, comment from books where id=?");
          st.setInt(1, id);
          ResultSet rs = st.executeQuery();
          rs.next();
         
          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.getDate("publishDate").getTime());
          book.setPublishDate(date);
          book.setComment(rs.getString("comment"));
         
          rs.close();
          st.close();
          conn.close();
         
          return book;
     }
    
     public List<Book> search(String name) {
          return null;
     }
    
     public int countAll() {
          return 0;
     }
    
     public Book update(Book book) {
          return null;
     }
    
     public List<Book> getAll() {
          return null;
     }
    
     public void deleteAll() {
         
     }
}

코딩을 빨리 해주시고 나면, 이 코드들을 보고 다음 질문에 답해주세요.

1. 정상적인 코드인지 확인해볼 수 있나요?
2. 중복된 코드가 존재하나요?
3. connection, statement, ResultSet 에 대한 반환은 이루어지고 있나요?

이중 하나라도 걸리게 된다면, 이 코드는 좋은 코드라고는 할 수 없습니다. 좋은 코드보다도 일단 정상적인 코드가 아닙니다. 
먼저, 이 코드가 정상적으로 돌아가는지를 확인하기 위해서 main method를 추가해서 동작을 확인해보도록 합니다.


Test code의 기초 - 코드의 동작을 확인하는 방법

코드의 동작을 확인하기 위해서 main 함수를 추가하도록 합니다. main 함수는 user를 생성해서 add method를 통해서 insert후, getAll() method를 통해서 입력된 모든 정보들이 정상적으로 입력이 되었는지를 확인하돌고 합니다. main 함수의 코드는 다음과 같습니다.


     public static void main(String[] args)
               throws InstantiationException,
                      IllegalAccessException,
                      ClassNotFoundException,
                      SQLException {
          System.out.println("start main app");
         
          Book book = new Book();
          book.setName("Spring 3.1");
          book.setAuthor("작가");
          book.setPublishDate(new java.util.Date());
          book.setComment("좋은 책입니다.");
         
          BookApp app = new BookApp();
          app.add(book);
         
          List<Book> books = app.getAll();
          for(Book b : books) {
               System.out.println(b);
          }
         
          return;
     }

위 코드를 실행시키면 다음과 같은 결과를 Console 창에서 확인 가능합니다.


start main app
Book [id=1, name=bookName01, author=author1, publishDate=Sat Jan 26 00:00:00 KST 2013, comment=null]
Book [id=4, name=Spring 3.1, author=작가, publishDate=Sat Jan 26 00:00:00 KST 2013, comment=좋은 책입니다.]

 이 방법이 좋은 것일까요? 이런식으로 개발하는 사람이 꽤나 많습니다. 이 방법의 문제가 어떤 것이 있을까요?

올바른 테스트란, 사람이 개입되어서는 안됩니다. 사람이 눈으로 테스트를 확인하는 경우는, 그것을 보지 않는다면 또는 테스트가 너무나 많아져서 테스트의 정보를 확인할 수 없다면 완전히 무용지물이 되어버리고 맙니다. 그리고, 개발을 해보시면 좀더 느끼시지만 하나를 만드니 다른쪽에서 에러가 발생할 수도 있습니다. 마지막으로 CI 환경에서 자동화된 build를 지원할 수가 없습니다.

따라서, 테스트는 확인 가능하나 완전 자동적인 코드로서 구성이 되어야지 됩니다. 테스트는 크게 2가지로 나눌 수 있습니다. 전에 CI에 대해서 간략하게 설명을 할 때, QA 부서에서 담당하기 전 단계라고 할 수 있는 단위 테스트(Unit TEST)와 QA 부서에서 진행하는 통합 테스트가 존재합니다. 잘 만들어진 개발 조직에서, 개발자는 자신의 단위 테스트를 유지하고 관리할 의무를 가지고 있습니다. 그리고 자신의 코드가 단위적으로는 에러가 발생하지 않는다는 결과를 보여줄 수 있는 방법이 있어야지 됩니다. 이를 위해 java에서는 junit이라는 테스트 도구를 배포하고 있고, 이 테스트 코드를 사용하면 자동화 되고, 확인이 가능한 코드로서 사용할 수가 있습니다.

junit은 version 3.x 와 4.x간의 큰 차이를 가지고 있습니다. 기존 3.x대에서는 TestCase class를 상속받아서 처리를 했으나, 지금은 @Test annotation을 이용하는 것만으로 쉽게 테스트 코드를 작성할 수 있습니다.

junit을 사용할 때 기억할 annotation 목록입니다.

1. @Test : Test method를 지정할 때 사용합니다. Test method는 반드시 public에 return type은 void, input 값은 하나도 없는 형태여야지 됩니다.
2. @Before : Test method를 시작하기 전에 반드시 실행될 method입니다.
3. @After : Test method를 수행 후, 실행될 method입니다.
4. @BeforeClass : 전체 테스트 코드 method가 수행되기 전에 실행됩니다. 반드시 public, static type의 method여야지 됩니다.
5. @AfterClass : 전체 테스트 코드 method가 수행된 후에 실행됩니다. 반드시 public, static type의 method여야지 됩니다.

테스트 코드의 annotation을 모두 사용한 테스트 코드입니다. 한번 내용을 확인해보도록 하겠습니다.

public class AppTest {
     @BeforeClass
     public static void beforeClass() {
          System.out.println("#1. BeforeClass");
     }
    
     @AfterClass
     public static void afterClass() {
          System.out.println("#6. AfterClass");
     }
    
     @Before
     public void before() {
          System.out.println("#2. Before");
     }
    
     @After
     public void after() {
          System.out.println("#3. After");
     }
    
     @Test
     public void test01() {
          System.out.println("#4. Test01");
     }
    
     @Test
     public void test02() {
          System.out.println("#5. Test02");
     }
    
     @Test
     public void test03() {
          System.out.println("#6. Test03");
     }
}

output :

#1. BeforeClass
#2. Before
#4. Test01
#3. After
#2. Before
#5. Test02
#3. After
#2. Before
#6. Test03
#3. After
#6. AfterClass

자. 지금까지 구성된 application 의 전체 테스트 코드를 작성해보도록 합니다. 모든 method가 다 테스트가 되어야지 되며, 테스트에 대한 내용은 자동으로 확인이 될 수 있도록, 예측되는 값들을 assert 문으로 확인이 가능해야지 됩니다.
테스트 코드를 작성해보고 그 결과를 한번 확인해보도록 하겠습니다.

public class BookAppTest {
    private BookApp bookApp;

    private List<Book> getBooks() {
        Book book1 = new Book();
        book1.setId(1);
        book1.setName("book name01");
        book1.setAuthor("autor name 01");
        book1.setComment("comment01");
        book1.setPublishDate(new Date());

        Book book2 = new Book();
        book2.setId(2);
        book2.setName("book name02");
        book2.setAuthor("autor name 02");
        book2.setComment("comment02");
        book2.setPublishDate(new Date());

        Book book3 = new Book();
        book3.setId(3);
        book3.setName("book name03");
        book3.setAuthor("autor name 03");
        book3.setComment("comment03");
        book3.setPublishDate(new Date());

        List<Book> books = Arrays.asList(book1, book2, book3);
        return books;
    }

    private void compareBook(Book book) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        Book dbBook = bookApp.get(book.getId());
        assertThat(dbBook.getName(), is(book.getName()));
        assertThat(dbBook.getAuthor(), is(book.getAuthor()));
        assertThat(dbBook.getComment(), is(book.getComment()));
        assertThat(dbBook.getPublishDate().toString(), is(book.getPublishDate().toString()));
    }

    @Before
    public void setUp() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        bookApp = new BookApp();
    }

    @Test
    public void addAndCount() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        List<Book> books = getBooks();
        int count = 0;
        for(Book book : books) {
            bookApp.add(book);
            count++;
            assertThat(bookApp.countAll(), is(count));
        }
    }

    @Test
    public void update() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        List<Book> books = getBooks();
        int count = 0;
        for(Book book : books) {
            bookApp.add(book);
            count++;
            assertThat(bookApp.countAll(), is(count));

            book.setName("changed name");
            book.setPublishDate(new Date());
            book.setAuthor("changed author");
            bookApp.update(book);

            compareBook(book);
        }
    }

    @Test
    public void getAll() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        List<Book> books = getBooks();
        int count = 0;
        for(Book book : books) {
            bookApp.add(book);
            count++;
            assertThat(bookApp.countAll(), is(count));
        }

        List<Book> books2 = bookApp.getAll();
        assertThat(books2.size(), is(books.size()));
    }

    @Test
    public void search() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
        List<Book> books = getBooks();
        int count = 0;
        for(Book book : books) {
            bookApp.add(book);
            count++;
            assertThat(bookApp.countAll(), is(count));
        }

        List<Book> searchedBooks = bookApp.search("01");
        assertThat(searchedBooks.size(), is(1));

        searchedBooks = bookApp.search("02");
        assertThat(searchedBooks.size(), is(1));

        searchedBooks = bookApp.search("03");
        assertThat(searchedBooks.size(), is(1));

        searchedBooks = bookApp.search("name");
        assertThat(searchedBooks.size(), is(3));
    }
}


자신이 만든 테스트 코드에 대한 확인을 하는 과정 역시 중요합니다. 테스트에 대한 확인 방법은 테스트가 정상적으로 마쳐지는지를 확인하고, code coverage를 통해서, test를 통해 자신의 코드가 얼마나 잘 테스트가 이루어졌는지를 확인하는 것이 필요합니다.  code coverage는 eclipse의 EclEmma를 통해서 확인 가능합니다. 


중복된 코드의 제거

다음은 중복된 코드의 제거입니다. 중복된 코드를 제거하는 것은 굉장히 중요한 일입니다. 만약에 프로그램 상에 오류가 발생하거나, 로직이 바뀌게 된다면 그 부분에 대하여 전 코드를 다 바꿔주야지 되는 상황이 발생합니다. 그렇지만, 중복된 코드를 하나의 코드로 작성해준다면, 한 method 또는 code block만 변경을 시키면 그 변경사항을 다른 코드에서도 사용할 수 있기때문에 최소한의 변경으로 원하는 결과를 얻어낼 수 있습니다.
중복되는 코드를 하나만 생각을 해서는 안됩니다. 이 코드가 같은 일을 하는 객체가 또 있다면 어떻게 되는걸까. 라는 질문을 자신에게 해봐야지 됩니다. 지금 있는 객체중에서 가장 문제가 될 수 있는 중복 영역에 대해서 알아보도록 하겠습니다.
만들어진 코드의 중복된 코드는 명확합니다. connection을 얻어내는 곳과 ResultSet을 이용한 Book bean과의 convert 영역이지요.

connection을 얻는 코드와 Book으로 변환하는 코드를 다음과 같이 변경해서 공통 method로 뽑아냅니다. 

     private Connection getConnection() throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          String url = "jdbc:mysql://localhost/bookstore";
          Class.forName("com.mysql.jdbc.Driver").newInstance();
          Connection conn = DriverManager.getConnection (url, "root", "qwer12#$");
         
          return conn;
     }
    
     private Book convertToBook(ResultSet rs) throws SQLException {
          Book book = new Book();
          book.setId(rs.getInt(1));
          book.setName(rs.getString(2));
          book.setAuthor(rs.getString(3));
          java.util.Date date = new java.util.Date(rs.getTimestamp(4).getTime());
          book.setPublishDate(date);
          book.setComment(rs.getString(5));
         
          return book;
     }


connection, statement, ResultSet에 대한 반환

지금까지 작성한 코드는 실은 엄청난 버그를 하나 가지고 있습니다. RDBMS에 connection을 맺고, sql query를 실행하고, 그 결과를 얻어내는 것은 프로그램 영역에서는 IO 접근과 동일합니다. 이건 파일을 접근하는 것과 동일한 상황이지요. 따라서, 모든 Programming Language에서는 IO에 대한 자원을 매우 소중히 여기고 있습니다. 이 IO에 대한 자원 해재는 추후에 엄청난 사태를 불러올 수 있기 때문에 명확히 해줘야지 되는 문제입니다. 지금 개발 된 코드에서 get(int id) method를 기준으로 자원 해재를 시켜주면 다음과 같습니다.


    public Book get(int id) throws InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException {
          Connection conn = getConnection();
          PreparedStatement st = null;
          ResultSet rs = null;
         
          try {
               st = conn.prepareStatement("select id, name, author, publishDate, comment from books where id=?");
               st.setInt(1, id);
               rs = st.executeQuery();
               rs.next();
               return convertToBook(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) {}
               }
          }
     }


여기까지 작성된 application을 보면 또 다른 코드의 중복이 나타나게 됩니다. 역시 자원을 얻고, 해재하는 과정 자체가 코드의 중복으로 나타나게 되는데요. 이를 해결하는 방법이 무엇일까에 대해서 고민을 해봐야지 됩니다. 여기에서 중복되는 코드는 connection을 얻고, 얻은 connection에 대하여 해재를 하는 상단과 하단은 완전히 동일한 코드가 나타나게 됩니다. 그렇지만, 중간의 preparedStatement와 ResultSet을 이용하는 부분은 각각 코드들마다 다른 모습을 보이게 되는데요. 이 부분을 어떻게 하면 해결해줄지를 한번 고민해보도록 합시다.

만약에 code block을 넣어주면 해결될수 있지 않을까? 라는 고민을 하셨으면 정답이라고 할 수 있습니다. 

이런 code block을 callback이라는 용어로 사용하고, callback을 호출하는 구문을 template이라고 합니다. 이유는 template에서 call이 되고, template의 code로 back 하는 구조를 가진 code pattern을 template-callback pattern이라고 합니다.
callback을 만들때는 interface로 만드는 것이 일반적이고, 대부분 inner interface로 구성되는 경우가 많습니다. 

지금 만든 BookApp의 Template 적 요소는 2가지로 볼 수 있습니다. return값을 갖는 경우와 return값을 갖지 않는 경우로 나눌수 있습니다. 그리고, 이 두가지 경우는 input값이 ResultSet을 갖는 method와 ResultSet을 갖지 않는 method로 구분할 수 있습니다. 이 두가지 경우를 각각 나눈 Template code와 Template code의 인자가 되는 callback은 다음과 같습니다. 

    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) {}
            }
        }
    }

callback interface를 2개를 만들고, template을 역시 2개 작성하였습니다. 


위 3가지 요소를 모두 반영한 BookApp의 전체 코드는 다음과 같습니다. 자신이 직접 코드를 만들어서 과정을 따라가보시길 바랍니다. 

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;

import com.xyzlast.bookstore01.entities.Book;

public class BookApp {
    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"));

        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) 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());
                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=? where id=?");
                st.setInt(5, 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());
                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;
    }
}

지금까지 간단한 RDBMS에 대한 CRUD 코드에 대해서 조금 깊게 들어가봤습니다. 
다음은 Spring을 통해서 이러한 잘못된 코드를 어떻게 해서 좋은 코드로 수정을 하게 되는지에 대한 과정을 알아보도록 하겠습니다. 


Posted by Y2K
,